User Profiles API
Read and update user profiles, preferences, avatars and account settings.
Path correction
There is no /api/v1/profiles route family. Everything on this page lives under /api/v1/users. Earlier revisions of this page documented /profiles/:id, /profiles/me and /profiles/me/privacy — those endpoints have never existed and return 404.
Identifiers
Public read endpoints accept either a profile UUID or the account's profile_slug in the :id position. A slug that has since been changed still resolves, because retired slugs stay bound to the account that used them.
Endpoints that write (PATCH /users/:id, the avatar routes, the preferences routes, POST /users/:id/deactivate) compare :id against the session's own user id with an exact string match. Pass the UUID on those — a slug fails the ownership check and returns 403, even for your own account.
Get a User Profile
GET /api/v1/users/:id
Public profile lookup. Authentication is optional: signing in changes only what your own profile returns.
const API_BASE = 'https://api.fundlyhub.org/api/v1';
// By UUID or by profile_slug — both work
const response = await fetch(`${API_BASE}/users/jane-doe`, {
credentials: 'include'
});
const profile = await response.json();
// Response format
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Jane Doe",
"display_name": "Jane Doe",
"avatar": "https://<cdn-host>/uploads/avatars/<user-id>-<timestamp>.webp",
"bio": "Community organizer",
"location": "Sacramento, CA",
"website": "https://example.com",
"social_links": { "twitter": "https://x.com/janedoe" },
"profile_visibility": "public",
"profile_slug": "jane-doe",
"account_status": "active",
"account_kind": "individual",
"role": "visitor",
"campaign_count": 3,
"total_funds_raised": 12500,
"follower_count": 42,
"following_count": 7,
"kyc_verified_at": null,
"created_at": "2024-01-01T00:00:00Z"
}Visibility rules
emailis included only when the caller is the profile owner. It is omitted for every other viewer.- When
profile_visibilityisprivateand the caller is not the owner, the response is redacted down toid,name,avatar,created_atandprofile_visibility: "private".bio,locationandwebsitecome backnull,social_linksis{},roleis"visitor", and the four counters (campaign_count,total_funds_raised,follower_count,following_count) are all0. The response is still200— a private profile is not a404. phoneandprivate_contact_emailare present only when the underlying row has them and the viewer is the owner; they are never part of a redacted payload.avatarisnullwhen the account has no picture or has removed one — it is not an empty string, and there is no server-side placeholder image. Render your own fallback.account_kindis resolved server-side and is one ofindividual,team,ambassadorororg. It is derived from the account's roles and organization membership. It is public on purpose — the badge is a trust signal a stranger is meant to see.
A user that does not exist returns 404:
{ "error": "User not found", "message": "The requested user profile does not exist" }Update a Profile
PATCH /api/v1/users/:id 🔒 Requires Authentication
Updates your own profile. :id must be your profile UUID. Every field is optional; omitted fields are left untouched.
| Field | Type | Rules |
|---|---|---|
name | string | 2–100 characters after trimming. |
profile_slug | string | Lower-cased before validation. 3–30 characters, a–z, 0–9 and hyphens, and it cannot start or end with a hyphen. admin, settings, api, login, signup, help, support, about and contact are reserved. A slug already in use, or previously used by a different account, is rejected — you can always re-claim a slug you used before. |
social_links | object | At most 10 entries. Each value must parse as a URL and be under 500 characters. Empty values are dropped rather than stored. |
location | string | "City, ST" — letters, spaces, hyphens, apostrophes and periods, then a comma, then a two-letter state. Maximum 80 characters. No digits, so a street address or ZIP cannot be stored here. An empty string clears the field. |
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const userId = '123e4567-e89b-12d3-a456-426614174000';
const response = await fetch(`${API_BASE}/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: 'Jane Doe',
profile_slug: 'jane-doe',
location: 'Sacramento, CA',
social_links: { twitter: 'https://x.com/janedoe' }
})
});
const result = await response.json();
// { "success": true, "message": "Profile updated" }Editing someone else's profile returns 403:
{ "error": "Forbidden: Can only update your own profile" }Not every rejection is a 400
Length and format failures, a reserved slug and a slug that is already taken all return 400 with the specific reason in error. Three other rejections currently fall through to a generic 500 with {"error":"Internal Server Error","message":"Failed to update profile"} and no detail: an unparseable social_links URL, more than 10 social links, and a profile_slug previously used by another account. Validate those client-side — a 500 here does not necessarily mean the server is unhealthy.
Check Slug Availability
GET /api/v1/users/check-slug/:slug
Public, unauthenticated. Use it to validate a handle before submitting a PATCH. Pass ?exclude=<your-user-id> so your current slug does not report itself as taken.
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const userId = '123e4567-e89b-12d3-a456-426614174000';
const response = await fetch(
`${API_BASE}/users/check-slug/jane-doe?exclude=${userId}`
);
const { available } = await response.json();
// { "available": true }This endpoint only reports availability. It does not enforce the length, character-set or reserved-word rules — PATCH /users/:id does that, so an available: true slug can still be rejected on save.
Upload an Avatar
POST /api/v1/users/:id/avatar 🔒 Requires Authentication
The body is JSON with base64 content, not multipart/form-data.
contentTypemust be one ofimage/jpeg,image/png,image/webp.- The decoded image must be 5 MB or smaller.
- The stored image is resized to 256×256 and converted to WebP, so the returned URL will not match the format you uploaded.
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const userId = '123e4567-e89b-12d3-a456-426614174000';
const file = document.querySelector('input[type="file"]').files[0];
const fileBase64 = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]); // strip data: prefix
reader.readAsDataURL(file);
});
const response = await fetch(`${API_BASE}/users/${userId}/avatar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ fileBase64, contentType: file.type })
});
const { success, avatar_url } = await response.json();Missing fileBase64 or contentType, a disallowed MIME type, an oversized file or a payload that is not actually an image all return 400 with the reason in error.
Delete an Avatar
DELETE /api/v1/users/:id/avatar 🔒 Requires Authentication
const response = await fetch(`${API_BASE}/users/${userId}/avatar`, {
method: 'DELETE',
credentials: 'include'
});
// { "success": true }Preferences
GET /api/v1/users/:id/preferences 🔒 Requires Authentication
Own preferences only — any other :id returns 403 ({"error":"Forbidden: Can only access your own preferences"}).
An account that has never saved preferences gets the defaults back rather than a 404.
const API_BASE = 'https://api.fundlyhub.org/api/v1';
const response = await fetch(`${API_BASE}/users/${userId}/preferences`, {
credentials: 'include'
});
const prefs = await response.json();
// Defaults returned when no row exists yet
{
"user_id": "123e4567-e89b-12d3-a456-426614174000",
"view_mode": "grid",
"theme": "system",
"recent_searches": [],
"search_suggestions": true,
"email_notifications": true,
"push_notifications": false,
"reduced_motion": false,
"high_contrast": false,
"font_size": "medium",
"has_completed_onboarding": false,
"has_skipped_onboarding": false,
"last_visited": "2024-01-01T00:00:00Z",
"auto_save": true,
"default_category": "All",
"suppressed_scopes": []
}suppressed_scopes is attached to both the stored row and the defaults. It reports which notification scopes are blocked for the account's email address — unsubscribe links are address-keyed and cannot write the preferences row, so a toggle can read as "on" while mail is still suppressed. Check this field before telling a user their notifications are enabled.
Update Preferences
PUT /api/v1/users/:id/preferences 🔒 Requires Authentication
A partial write: send only the keys you want to change and everything else keeps its stored value. The full stored row is returned.
Alongside the keys shown above, the following notification switches are stored: notify_donations, notify_comments, notify_updates, notify_milestones, notify_campaign_status, notify_followers, notify_org_status, notify_payouts, notify_digest, notify_donation_reminders, notify_endorsement_requests (emails asking you, as an ambassador, to share a campaign), and admin_theme.
const response = await fetch(`${API_BASE}/users/${userId}/preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ theme: 'dark', notify_digest: false })
});
const updated = await response.json();Account Settings
These four operate on the authenticated user and take no :id — the target is always derived from the session.
Private Contact Email
PUT /api/v1/users/me/private-contact 🔒 Requires Authentication
Sets the contact address visible only to the FundlyHub team. Disposable-email domains are rejected with 400. Setting an address that is already your verified sign-in address skips re-verification.
const response = await fetch(`${API_BASE}/users/me/private-contact`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: 'jane@example.com' })
});
// { "success": true, "email": "jane@example.com", "verified": false,
// "message": "Private contact email updated. A verification email has been sent." }POST /api/v1/users/me/private-contact/resend 🔒 Requires Authentication
Re-sends the verification email. Budgeted per user rather than per IP, so a shared network or office does not exhaust one person's allowance.
Phone Number
PUT /api/v1/users/me/phone 🔒 Requires Authentication
Accepts digits, spaces, hyphens, parentheses, periods and a leading +, 7–20 characters total. The number is stored only — there is no SMS verification step today, so a saved number is not a verified one.
const response = await fetch(`${API_BASE}/users/me/phone`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ phone: '+1 (555) 010-1234' })
});
// { "success": true, "phone": "+1 (555) 010-1234", "message": "Phone number saved." }Publish Readiness
GET /api/v1/users/me/publish-readiness 🔒 Requires Authentication
The checklist behind the campaign publish gate.
const response = await fetch(`${API_BASE}/users/me/publish-readiness`, {
credentials: 'include'
});
const readiness = await response.json();
// Response format
{
"ready": false,
"checklist": {
"private_contact_email": true,
"private_contact_verified": false,
"display_name": true,
"avatar": true,
"phone": false
},
"blockers": ["private_contact_verified", "phone"],
"suggestions": [
"Add social media links (Twitter, Instagram, LinkedIn, etc.) to build trust with donors."
]
}checklist keys are hard requirements — ready is true only when all of them pass. suggestions never block publishing. Two extra keys, no_contact_issue and not_under_review, appear only when they are actually blocking, so do not treat a missing key as a failure.
Deactivate an Account
POST /api/v1/users/:id/deactivate 🔒 Requires Authentication
Self-service deactivation of your own account. :id must be your own UUID. On success the API clears the access_token, id_token and refresh_token cookies, so the caller is signed out by the same response.
const response = await fetch(`${API_BASE}/users/${userId}/deactivate`, {
method: 'POST',
credentials: 'include'
});
// { "success": true, "message": "Account deactivated. You will be logged out." }When something blocks deactivation the API returns 409 with the reason in error rather than 400.
Profile Sub-Resources
These hang off a profile. The Id column says which identifiers the endpoint resolves — only some of them accept a profile_slug; the rest need the profile UUID.
| Endpoint | Id | Auth | Returns |
|---|---|---|---|
GET /api/v1/users/:id/organizations | UUID or slug | Optional | { "data": [...] } — the user's public org memberships. 404 only when the user itself does not exist; a user with no public memberships gets 200 and an empty array. |
GET /api/v1/users/:id/campaigns | UUID or slug | Optional | { "data": [...] } — campaigns the user runs plus ones they have endorsed. Private statuses are unlocked by the session, never by a query parameter. Accepts limit. |
GET /api/v1/users/:id/donation-activity | UUID or slug | Optional | { "data": { total_donated_cents, causes_supported_count, recent_supported_causes } }. Anonymous donations are excluded; opted-out users and private profiles return zeros rather than an error. |
GET /api/v1/users/:id/og-image | UUID or slug | None | An image/png Open Graph card for the profile, cached for 5 minutes. Not JSON. |
GET /api/v1/users/:id/badges | UUID | Optional | { "badges": [{ id, badgeType, metadata, awardedAt }] }, newest first. |
GET /api/v1/users/:id/followers | UUID | Optional | An array of follower summaries. Accepts limit (default 20, max 100) and offset. Emails are always null. |
GET /api/v1/users/:id/following | UUID | Optional | The accounts this user follows, same paging. |
GET /api/v1/users/:id/permissions | UUID | 🔒 Self, or view_all_users | { roles, permissions, roleDefinitions } for the user. Anyone else gets 403. For your own permissions prefer GET /me/capabilities. |
GET /api/v1/users/:id/impact | UUID or slug | Optional | The Impact figures shown on a profile (what an ambassador's links drove). |
GET /api/v1/users/:id/achievements | UUID or slug | Optional | Achievements the user has made public. |
GET /api/v1/users/:id/activity | UUID or slug | Optional | The public activity feed (flag features.activity_feed). |
See Roles & Permissions for what the role and permission names mean.
Response Codes
200- Success400- Validation error (the reason is inerror)401- Authentication required403- Attempted to read or write another user's data404- User not found409- Deactivation blocked429- Rate limit exceeded (see Rate Limits)500- Server error