Skip to main content
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.

Declaring storage

Declare two scope maps: account (shared by all users in an account) and user (personal to each user):
Pass the type as a generic to defineIntegration<MyStorage>:

Reading and writing

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:
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.
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.

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:

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:
  • ✅ 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:

Temporary state with expiry

Use expiresAt for state that should auto-clean:

User preferences

User scope is server-readable only, so a schedule or tool can read it, but a widget cannot:
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.

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