Storing user profile fields without your own users table
The metadata object on a user record replaces most of a profiles table — with one replace-not-merge rule that will bite you, and the point where you want a real table.
Most users tables in small products hold an id, an email, a display name and half a dozen flags. Infrai’s user record already carries all of that: POST /v1/auth/user/create and PATCH /v1/auth/user/update/{user_id} accept a free-form metadata object, GET /v1/auth/user/get/{user_id} returns it, and GET /v1/auth/user/list pages the directory with metadata attached. For a product at that size you can genuinely skip the table.
There’s one rule that decides whether this works or quietly corrupts data, and it’s worth knowing before you write the first update.
metadata replaces, it does not merge
curl -sS -X PATCH "https://api.infrai.cc/v1/auth/user/update/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"metadata": {"theme": "dark"}
}'
Send that and metadata becomes exactly {"theme": "dark"}. Whatever else lived there — the tenant id, the onboarding step, the notification preferences another team added last month — is gone.
Read, merge, write. Every time.
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def patch_metadata(user_id: str, changes: dict) -> dict:
"""Read-merge-write, because the update replaces the whole object. Doing this
in one helper is the difference between a profile store and a data-loss bug
waiting for two features to ship in the same week."""
current = SESSION.get(f"{API}/v1/auth/user/get/{user_id}", timeout=10)
if current.status_code == 404:
raise KeyError("no such user")
current.raise_for_status()
existing = current.json()["data"].get("metadata") or {}
merged = {**existing, **changes}
resp = SESSION.patch(
f"{API}/v1/auth/user/update/{user_id}",
json={"user_id": user_id, "metadata": merged},
timeout=10,
)
resp.raise_for_status()
return resp.json()["data"]["metadata"]
if __name__ == "__main__":
print(patch_metadata(os.environ["USER_ID"], {"theme": "dark", "onboarding_step": 3}))
Put that function somewhere central and forbid direct PATCH calls in review. The alternative is discovering the rule the day two features write metadata in the same session.
There’s a race in there, of course. Read-merge-write isn’t atomic, so two concurrent updates can lose one of the changes — acceptable for preferences, not acceptable for anything you’d call a balance.
What the record already gives you
curl -sS "https://api.infrai.cc/v1/auth/user/get/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"email": "ada@example.com",
"email_verified": true,
"phone": null,
"name": "Ada",
"mfa_enabled": false,
"created_at": "2026-09-03T07:25:21Z",
"last_login_at": "2026-09-21T02:24:30Z",
"metadata": {"tenant_id": "t_northwind", "theme": "dark"},
"vendor": "infrai_native"
}
}
last_login_at is the field people rebuild unnecessarily — it’s already maintained, so your “inactive for 90 days” report is a directory scan rather than an event pipeline.
email_verified and mfa_enabled are similarly free. Three columns you didn’t have to add.
Paging the directory
curl -sS "https://api.infrai.cc/v1/auth/user/list?limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
items, next_cursor, total. Follow the cursor until it’s null.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export async function* allUsers(pageSize = 100) {
let cursor = null;
do {
const url = new URL(`${API}/v1/auth/user/list`);
url.searchParams.set("limit", String(pageSize));
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } });
const { data } = await res.json();
for (const user of data.items ?? []) yield user;
cursor = data.next_cursor;
} while (cursor);
}
// Example: everyone on a tenant, filtered client-side because the directory has
// no server-side metadata filter.
export async function usersInTenant(tenantId) {
const out = [];
for await (const u of allUsers()) {
if ((u.metadata ?? {}).tenant_id === tenantId) out.push(u);
}
return out;
}
Where you want a real table after all
| Need | metadata is fine | you want a table |
|---|---|---|
| Preferences, flags, onboarding state | yes | — |
| Tenant id, role | yes, with an audited write path | if it changes often |
| Query by a field | no server-side filter | yes, once you have thousands |
| Anything transactional | read-merge-write races | yes |
| Large blobs, history, relations | no | yes |
The honest boundary: metadata has no query capability and no transactional guarantee. The moment a product manager asks for “all users whose plan is pro and who haven’t logged in for a month, sorted by spend”, you’re scanning the whole directory in your process — that’s a limitation of the design, not a bug, and the answer is a small table of your own indexed on the fields you actually query while identity stays here.
One more thing you don’t have to build: the avatar. PUT /v1/storage/object/put/{bucket}/{key} puts the image on the same key that holds the profile, and the URL goes in metadata — no second storage vendor, no second bill, and the usage shows up in the same GET /v1/account/usage as everything else.
All four routes here report billing_class: free in discovery, so profile reads and writes aren’t billed per call; identity is metered per monthly active user the way Auth0 meters it. Read your own accrued figure from GET /v1/account/usage (verified 2026-09-21) instead of any published number, and expect platform rates to drift down over time rather than up.