User Metadata
Attach custom data to users with public and private metadata fields in HelloJohn.
User Metadata
HelloJohn lets you attach arbitrary JSON data to every user through two metadata fields. Use metadata to store application-specific information alongside the user record — without building a separate user table.
Metadata Types
| Field | Visible To | Set By | Use Cases |
|---|---|---|---|
public_metadata | User (client-side) | Backend only | Plan, features, preferences, onboarding state |
private_metadata | Backend only | Backend only | Internal IDs, billing info, sensitive data |
Both fields accept any valid JSON object up to 64 KB.
Setting Metadata
Metadata can only be set via the Admin API (secret key). This prevents users from granting themselves elevated access.
curl -X PATCH "https://api.hellojohn.dev/v1/users/usr_01HABCDEF123456" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{
"public_metadata": {
"plan": "pro",
"onboarding_completed": true,
"feature_flags": ["beta_ui", "new_dashboard"]
},
"private_metadata": {
"stripe_customer_id": "cus_abc123",
"internal_user_id": 9001
}
}'Metadata is replaced entirely on PATCH, not merged. Read the current value first and spread it to preserve existing keys.
Merging Metadata
// Read → merge → write
const user = await hj.users.get(userId);
await hj.users.update(userId, {
public_metadata: {
...user.public_metadata,
onboarding_completed: true, // add/override one key
},
});Reading Metadata
Public metadata (client-side)
import { useUser } from "@hellojohn/react";
function PlanBadge() {
const { user } = useUser();
const plan = user.public_metadata?.plan ?? "free";
return <span className={`badge-${plan}`}>{plan.toUpperCase()}</span>;
}Private metadata (backend only)
// Backend — requires secret key
const user = await hj.users.get(userId);
const stripeId = user.private_metadata?.stripe_customer_id;
if (!stripeId) {
// Create Stripe customer and save ID
const customer = await stripe.customers.create({ email: user.email });
await hj.users.update(userId, {
private_metadata: {
...user.private_metadata,
stripe_customer_id: customer.id,
},
});
}Metadata in JWT
public_metadata is included in the JWT access token. This lets your backend read plan/feature info without a database lookup:
{
"sub": "usr_01HABCDEF123456",
"public_metadata": {
"plan": "pro",
"feature_flags": ["beta_ui"]
}
}Check feature flags on the backend:
function requireFeature(flag: string) {
return (req: Request, res: Response, next: NextFunction) => {
const flags = req.token.public_metadata?.feature_flags ?? [];
if (!flags.includes(flag)) {
return res.status(403).json({ error: "Feature not available on your plan" });
}
next();
};
}
app.get("/beta/dashboard", requireFeature("new_dashboard"), handler);Common Patterns
Onboarding State
// After user completes onboarding step
await hj.users.update(userId, {
public_metadata: {
...user.public_metadata,
onboarding: {
step: 3,
completed_steps: ["profile", "connect_github", "invite_team"],
completed_at: null,
},
},
});Feature Flags
// Grant beta access to a user
await hj.users.update(userId, {
public_metadata: {
...user.public_metadata,
feature_flags: [
...(user.public_metadata?.feature_flags ?? []),
"new_editor",
],
},
});External System IDs
// Store Stripe + Intercom IDs
await hj.users.update(userId, {
private_metadata: {
...user.private_metadata,
stripe_customer_id: customer.id,
intercom_contact_id: contact.id,
},
});Clearing Metadata
Set a field to an empty object to clear it:
curl -X PATCH "https://api.hellojohn.dev/v1/users/usr_01HABCDEF123456" \
-H "Authorization: Bearer sk_live_abc123" \
-H "X-Tenant-ID: tnt_01HABCDEF654321" \
-H "Content-Type: application/json" \
-d '{"public_metadata": {}}'Size Limits
| Field | Max Size |
|---|---|
public_metadata | 64 KB |
private_metadata | 64 KB |
Both must be JSON objects. Arrays or primitives at the root level are rejected.