App data buckets
Namespaced JSON storage on apps and per-user pivot rows — public, private and protected tiers.
Every app gets JSON storage with visibility tiers — useful for publishing app metadata, keeping server-side configuration, and storing per-user integration state without running your own database.
The two storage locations
On the app itself (one per app):
| Bucket | Read | Write |
|---|---|---|
public | Anyone, no auth | App owner |
private | App owner | App owner |
On the (app, user) pivot (one per Feeef user who interacts with your app):
| Bucket | Read | Write |
|---|---|---|
public | User + developer | User + developer |
private | Developer only | Developer only |
protected | User + developer | Developer only |
protected is the interesting one: the user can see it but not forge it — right for
entitlements, verification flags, subscription status.
JSON shape and limits
- Root must be an object; every top-level key must map to an object (a "namespace").
Top-level arrays, primitives and
nullare rejected with422. - Max 64 KiB serialized per bucket.
{
"settings": { "theme": "dark" },
"integrations": { "slack": { "enabled": true } }
}App-level endpoints
| Call | Auth | Notes |
|---|---|---|
GET /apps/:id/public-data | None | 404 if app missing or inactive |
PUT /apps/:id/public-data | Owner | Body { "public": {...} } — full replacement |
GET /apps/:id/private-data | Owner | Returns { "private": {...} } |
PUT /apps/:id/private-data | Owner | Full replacement |
The dashboard's Data tab edits both buckets with validation.
Per-user endpoints
User side — any signed-in Feeef user, for any active app:
| Call | Returns / accepts |
|---|---|
GET /apps/:id/user-data/me | { "public": {...}, "protected": {...} } — private is never returned |
PUT /apps/:id/user-data/me | Only { "public": {...} } — including private/protected keys → 422 |
Developer side — requires a token issued via the authorization-code flow for this app,
carrying the apps scope (or *), acting on the token's own user (:userId must equal
the token user):
| Call | Returns / accepts |
|---|---|
GET /apps/:id/user-data/users/:userId | All three buckets |
PUT /apps/:id/user-data/users/:userId | Any subset of public / private / protected — omitted buckets stay unchanged (partial update) |
403 causes: token not bound to this app, missing apps scope, :userId mismatch, or a
first-party login token (no app_id) on a developer route.
Typical pattern
Register your app with apps in its scopes, and request scope=auth apps at authorize.
After code exchange, the token row stores your app_id — the key that unlocks the developer
user-data routes.
Your backend writes protected state (plan, entitlements) after its own checks; your client
reads it back via GET /apps/:id/user-data/me and trusts it because users can't write it.
SDK methods
Both SDKs expose the same surface (JS feeef.apps.* / Dart Feeef.instance.apps.*):
| HTTP | SDK method |
|---|---|
GET …/public-data | getPublicData(appId) |
PUT …/public-data | putPublicData(appId, data) |
GET …/private-data | getPrivateData(appId) |
PUT …/private-data | putPrivateData(appId, data) |
GET …/user-data/me | getUserDataMe(appId) |
PUT …/user-data/me | putUserDataMe(appId, data) |
GET …/user-data/users/:userId | getUserDataForUser(appId, userId) |
PUT …/user-data/users/:userId | putUserDataForUser(appId, userId, body) |
GET …/public-data is unauthenticated by design — never put secrets in a public bucket,
and expect it to be cached. protected should be written only from your backend.