Overview
The project user model is the standard way to register users and organizations into a project. It separates three concerns into distinct calls:
- Project organizations — the customer entity that a group of users belongs to (a company, a team, a workspace in your product). Created once per organization with
/v1/api/projects/register-organization.
- Project users — an individual user inside an organization. Created once per user with
/v1/api/projects/register-user.
- JWT tokens — issued per session via
/v1/api/projects/user-token using a user’s ID.
Use this flow when you want many users to share one organization’s spaces, roles, and dashboards — and when you want your existing user / organization identifiers to be the same identifiers Upsolve sees.
Data Model
- One project organization can contain many users.
- One project user belongs to exactly one project organization.
- One role is assigned to each user and defines their permissions.
- One space is automatically created per (application, project organization) — that’s where users see and customize dashboards.
End-to-End Flow
1. Register an Organization (once per customer entity)
POST /v1/api/projects/register-organization
Authorization: Bearer <YOUR_API_KEY>
Content-Type: application/json
{
"projectId": "418c807a-83e7-4d95-8c8a-c7919dab180f",
"name": "Acme Inc",
"externalId": "acme-internal-uuid-1234",
"properties": {
"tier": "enterprise"
}
}
Response
{
"projectOrganizationId": "7c2b9d52-1f8a-4d31-bcaa-a91d4e5e9c10",
"externalId": "acme-internal-uuid-1234"
}
externalId — what it is and why you want it
externalId is any opaque string you control — typically the identifier your application already uses to reference the organization (e.g. a company UUID). Upsolve stores it alongside the Upsolve-issued projectOrganizationId and uses it to resolve subsequent calls.
Why this matters: wherever an Upsolve endpoint accepts projectOrganizationId, it now accepts either the Upsolve UUID or the externalId. The resolver tries the UUID lookup first and falls back to externalId within the same organization + project scope. So once an externalId is set, your application never needs to persist Upsolve’s UUID — it can keep using the identifier it already has.
Use it when:
- You already have a stable identifier for your organizations and don’t want to store a second one.
- You want
/register-user calls to read naturally: projectOrganizationId: "acme-internal-uuid-1234".
Leave it out (externalId is optional) if you’re happy to persist Upsolve’s UUID.
externalId is unique within a project + organization. Two project
organizations in the same project cannot share the same externalId. If you
attempt to create one with a duplicate, you’ll get a 409 Conflict.
Side effects of /register-organization
- A row is inserted into
project_organizations.
- For every existing application in the project, a space is created. Each space is automatically populated with the application’s published dashboard templates (lazy fork — templates render directly and only persist once a user customizes them).
- A
space_initialization_jobs row is created per space for traceability.
If you add a new application later, spaces for existing project organizations are not retroactively created in this call — they’re created the next time the app’s first dashboard is opened.
2. Register a User (once per user account)
POST /v1/api/projects/register-user
Authorization: Bearer <YOUR_API_KEY>
Content-Type: application/json
{
"projectId": "418c807a-83e7-4d95-8c8a-c7919dab180f",
"projectOrganizationId": "acme-internal-uuid-1234",
"userRoleId": "62b91a8d-3e45-4a91-9f80-ab1234567890",
"name": "Jane Doe",
"userId": "0c1c4a3f-b2d4-4f1e-9c54-9e9f9f9f9f9f",
"properties": {
"department": "Finance",
"region": "EU"
}
}
Response
{
"message": "User Jane Doe registered.",
"userId": "0c1c4a3f-b2d4-4f1e-9c54-9e9f9f9f9f9f"
}
Notes
projectOrganizationId resolves polymorphically — pass either the Upsolve UUID or the externalId you set in step 1.
userId is optional. If you supply your own UUID, that becomes the user’s permanent identifier in Upsolve — you can re-use it on every future /user-token call without persisting Upsolve’s user UUID. If you omit it, Upsolve generates one and returns it.
userRoleId must refer to a project user role that exists in the same project. Roles define permissions (addChart, editCharts, etc.) and are reusable across users.
properties are arbitrary JSON. Each key is surfaced in row-level security (RLS) rules as a {{user.propertyName}} placeholder (e.g. a region key becomes {{user.region}}). See RLS & Schema Filtering.
Common errors
| Status | Cause |
|---|
400 Bad Request | Missing required field (name, projectId, userRoleId, projectOrganizationId). |
404 Not Found | projectOrganizationId doesn’t match any row’s id or external_id in your scope. |
404 Not Found | userRoleId doesn’t belong to the given project. |
409 Conflict | A user with the same userId or the same (project, name) already exists. |
3. Fetch a User Token (on every login / dashboard load)
POST /v1/api/projects/user-token
Authorization: Bearer <YOUR_API_KEY>
Content-Type: application/json
{
"userId": "0c1c4a3f-b2d4-4f1e-9c54-9e9f9f9f9f9f",
"expiration": "1h"
}
Response
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Pass the token as the jwt query parameter on your dashboard or application iFrame src (see Frontend Setup). Tokens expire after the requested duration (default 1h). Fetch a new token before expiry — changing the token causes the dashboard to refresh with the new user’s permissions.
/user-token does not require the project ID or organization ID. The JWT
payload is derived from the project_users row identified by userId — so
the token automatically carries the user’s project, organization,
permissions, and properties.
Standard Login Loop
The simplest stable backend integration is:
- On user sign-up: call
/register-organization (if needed) and /register-user. Store nothing about Upsolve — just remember that the user is registered.
- On every page load that mounts an Upsolve dashboard: call
/user-token with the user’s ID. Pass the returned token to the frontend.
- If
/user-token returns 404 User not found: the user isn’t registered yet — call /register-user then retry.
async function getUpsolveToken(user: User): Promise<string> {
try {
const { token } = await upsolve.post("/projects/user-token", {
userId: user.id,
});
return token;
} catch (err) {
if (err.status !== 404) throw err;
await upsolve.post("/projects/register-user", {
projectId: UPSOLVE_PROJECT_ID,
projectOrganizationId: user.organization.id, // your internal ID — works as externalId
userRoleId: roleForUser(user),
name: user.name,
userId: user.id,
properties: { ... },
});
const { token } = await upsolve.post("/projects/user-token", {
userId: user.id,
});
return token;
}
}
Security
- API key calls must go through your backend — never expose API keys to the browser.
- Tokens are scoped to a single project user. They cannot read other users’ data, even within the same project organization, unless your role grants it.
- Always use HTTPS.