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