Skip to the article
Aniq-UI

NorthstackAPI reference

API reference

Every route the API serves, who may call it, and how sign-in, workspaces, permissions, billing, API keys, errors, lists and limits work.

For the Full Stack package

Base URL and response format

Every route is served under the /api prefix. Locally the base URL is http://localhost:8000/api. On a server it is your API's address followed by /api, the same value the website and the dashboard read from NEXT_PUBLIC_API_BASE_URL (Environment variables). Files are served outside the prefix: the kit's brand files at /media/… and uploads at /media/uploads/….

Request bodies are JSON, except file uploads. Every answer comes in the same envelope:

Envelope
{
  "success": true,
  "data": { },
  "message": ""
}

data holds the result. message is a short code some writes fill in, such as project_created or invitation_sent, and is empty otherwise. Workspaces, projects and tasks are named by their public ids (org_forma, prj_01); members, invitations, keys and files by their number. Project and task texts carry both languages as { "en": { … }, "ar": { … } }.

The health check needs no token:

Terminal
curl http://localhost:8000/api/health
Response
{"success":true,"data":{"status":"ok","database":"up"},"message":""}

Until the database answers and holds its tables, it answers 503 with "status":"unavailable" and "database":"down". The Docker image's health check reads that status, and Docker Compose starts the website and the dashboard only once it passes.

Browsers may call the API only from the addresses in CORS_ORIGIN. Left unset it allows http://localhost:3030 and http://localhost:3031, and in production the API refuses to start without it (CORS problems).

Errors

A failure keeps the envelope, with success: false, data: null and the HTTP status:

Error
{
  "success": false,
  "data": null,
  "message": "seat_limit_reached",
  "errors": { "email": ["…"] }
}
  • errors appears on a validation failure (400), with the problems per field.
  • An unknown field in a body is refused with 400: every body takes only the fields listed below.
  • When the API has a sentence for the error, message is that sentence in the request's language (Accept-Language: ar for Arabic): a refused sign-in, every 429, closed sign-ups, an account guarded from a password or email change, a missing webhook secret, a subscription Stripe could not cancel, a platform role or permission the caller does not hold, and maintenance mode among them.
  • Otherwise, on 401, 403 and 404 message is a general sentence for the status, and on 400, 402, 409, 410 and 503 it is the reason as a short code (seat_limit_reached, slug_taken, over_plan_limit, payment_declined, preview_expired, invitation_expired, payment_unavailable), which a client turns into its own sentence. A downgrade refused for usage also carries blockers.
  • 402 means a plan upgrade the payment provider would not charge: payment_declined, payment_needs_action (the bank wants the payment confirmed) or payment_method_missing. The plan stays as it was.
  • While platform staff have maintenance mode on, every route outside the short list in Maintenance mode answers 503 to anyone who is not platform staff, with the sentence in message and code: "maintenance_mode".
  • 401 means no token, an expired one, or wrong credentials. 403 means signed in without the permission, or a write to a suspended workspace. 404 also answers for a workspace the caller is not a member of, so a stranger cannot tell which workspaces exist.
  • A request over a limit answers 429 (Limits per address).

Signing in

  1. Sign in

    Terminal
    curl -X POST http://localhost:8000/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"email":"owner@northstack.example","password":"YOUR_PASSWORD"}'

    Put the seeded password from Installation in place of YOUR_PASSWORD, or use an account of your own. data.access_token is the token, with token_type Bearer, expires_in, and admin, the account with its platform roles, permissions and workspaces.

  2. Call a protected route

    Terminal
    curl http://localhost:8000/api/workspaces \
      -H "Authorization: Bearer YOUR_TOKEN"

    That lists the account's workspaces with its role in each. Every workspace route then takes the workspace's public id in its path:

    Terminal
    curl "http://localhost:8000/api/workspaces/org_forma/projects?limit=20" \
      -H "Authorization: Bearer YOUR_TOKEN"
  • remember: true signs a token for 30 days and remember: false one for 12 hours; left out, the token lasts JWT_EXPIRATION, 7d by default.
  • An unknown email and a wrong password both answer 401 with the same message, after the same password-hashing work, so neither the answer nor its timing tells which emails have accounts.
  • Ten failed sign-ins from one address within 15 minutes answer 429 until the oldest failure is 15 minutes old (RATE_LIMIT_LOGIN). Successful sign-ins are not counted.
  • Tokens are stateless: POST /api/auth/logout answers at once, and signing out is the client forgetting its token.
MethodPathWho can call itWhat it does
POST/api/auth/loginAnyoneSign in: email, password, optionally remember
POST/api/auth/signupAnyoneA new account, signed in at once and ready for onboarding: name (2 to 80 characters), email, password (8 to 128) and acceptTerms (true). A taken email answers 409 with email_taken; while the platform's signups_enabled setting is off it answers 403
POST/api/auth/forgot-passwordAnyoneemail, optionally locale (en, ar). Mails a reset link that works once for one hour when the account exists, and answers the same either way
POST/api/auth/reset-passwordAnyonetoken (from the link) and the new password (8 to 128). A used or expired link answers 401
POST/api/auth/logoutAnyoneAnswers { signedOut: true }
GET/api/auth/meAny signed-in accountThe account, its platform roles and permissions, and its workspaces
PATCH/api/auth/meAny signed-in accountChange firstName, lastName, email or phone
PATCH/api/auth/me/preferencesAny signed-in accounttheme (system, light, dark), reducedMotion, locale (en, ar) and lastWorkspaceId, the workspace the next sign-in opens
PATCH/api/admins/profile/passwordAny signed-in accountChange the account's own password: current_password, password, password_confirmation

Workspaces and permissions

  • Workspace routes start with /api/workspaces/:workspaceId, the workspace's public id. On every request the API checks that the caller has an active membership there (otherwise 404) and holds the permission the route names (otherwise 403). The role is the membership's: one person can be the owner of one workspace and an admin of another.
  • A workspace holds four roles (owner, admin, member, billing) and its own matrix of 14 permissions: workspace.read, workspace.update, workspace.delete, projects.read, projects.write, members.invite, members.remove, roles.manage, billing.read, billing.manage, keys.manage, audit.read, ai.read and ai.apply. The owner holds them all, and workspace.delete and roles.manage stay with the owner whatever the matrix says. A change to the matrix applies to the next request.
  • A suspended workspace keeps every GET and refuses every other method with 403.
  • A past-due workspace keeps everything until BILLING_GRACE_DAYS (7 by default) have passed; then it cannot create projects, invitations or API keys (403), while every read and every other write keeps working.
  • Platform routes (/api/platform, /api/users, /api/admins, /api/roles, /api/settings) check the account's platform permissions instead, granted through platform roles. The seed creates Platform admin, with all 21, and Support, which reads every platform page and exports reports. A route that lists several permissions accepts any one of them.

Lists and paging

A list
{ "data": [ ], "total": 12, "page": 1, "limit": 7, "totalPages": 2 }
  • Projects, archived projects, activity, chat conversations and their messages, and the platform's organizations and users page with page and limit, at most 100 a page. The defaults are 7 projects, 12 events, 20 conversations and 10 organizations or users.
  • Staff, platform settings and platform roles page with page and page_count instead (10 by default, at most 100). Platform roles page only when both are given; otherwise every role is returned.
  • Lists come newest first (projects, users and organizations by their last change), with the id as a tiebreaker, unless a sort is given; platform roles come by name. The projects list also returns counts per tab.
  • What belongs to one workspace and is shown whole answers without paging: a board's tasks, members, invitations, API keys, a project's files, the latest 36 invoices and the latest 8 notifications.

Workspaces, members and the roles matrix

MethodPathWho can call itWhat it does
GET/api/workspacesAny signed-in accountThe account's workspaces with its role in each, for the switcher
GET/api/workspaces/slug-availabilityAny signed-in account?slug=: whether a workspace address is free, with a suggestion when it is not
POST/api/workspacesAny signed-in accountOnboarding: name (2 to 80), slug (3 to 48 lowercase letters, digits and hyphens), region (europe, north_america, asia_pacific) and optionally invitations, up to 20 { email, role }. The caller becomes the owner, on a Starter trial as long as the platform's trial_days. A taken slug answers 409 with slug_taken, and while signups_enabled is off it answers 403
GET/api/workspaces/:workspaceIdA member with workspace.readThe workspace: name, address, plan, usage against the plan's limits and its settings. billingEmail is null unless the caller holds billing.read or billing.manage
PATCH/api/workspaces/:workspaceIdA member with workspace.updateChange name, slug, locale (en, ar), timeZone (an IANA zone such as Europe/Berlin), domainConfigured, emailNotifications (the billing emails) or weeklyDigest
DELETE/api/workspaces/:workspaceIdThe workspace's ownerconfirmName (the workspace's name, typed out) and the owner's password. The workspace is soft-deleted, and its Stripe subscription is canceled first; if Stripe refuses, it answers 409 and the workspace is kept
GET/api/workspaces/:workspaceId/membersA member with workspace.readThe members with their roles. search, role
PATCH/api/workspaces/:workspaceId/members/:memberIdA member with roles.manageChange a member's role (admin, member, billing). The owner's row answers 403
DELETE/api/workspaces/:workspaceId/members/:memberIdA member with members.removeRemove a member. The owner cannot be removed, and an admin may not remove another admin
POST/api/workspaces/:workspaceId/members/transfer-ownershipThe workspace's ownermemberId and the owner's password. The member becomes the owner and the previous owner an admin
GET/api/workspaces/:workspaceId/rolesA member with workspace.readThe roles matrix: each permission, in six groups, and the roles that hold it
PUT/api/workspaces/:workspaceId/rolesThe workspace's ownerSave the matrix: matrix, each permission mapped to the roles that hold it. An unknown permission or role answers 400

Invitations

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/invitationsA member with workspace.readstatus (pending, the default, or all). Answers invitations and seats, the seats used and left on the plan
POST/api/workspaces/:workspaceId/invitationsA member with members.inviteInvite email as role (admin, member, billing). The link is mailed and works for 7 days. A full plan answers 409 with seat_limit_reached; already_member and already_invited are refused the same way
POST/api/workspaces/:workspaceId/invitations/:id/resendA member with members.inviteA new link, valid for 7 more days. Once a minute at most (resend_too_soon), and five times per invitation (resend_limit_reached)
DELETE/api/workspaces/:workspaceId/invitations/:idA member with members.inviteRevoke an invitation
GET/api/invitations/:tokenAnyoneWhat an invitation link opens: the workspace's name and address, the role, the invited email partly hidden, and whether it is still open
POST/api/invitations/:token/acceptThe invited account, signed inJoin the workspace. An expired or revoked link answers 410; an account whose email is not the invited one answers 403; a full plan answers 409 with seat_limit_reached

Invitation emails go through the API's SMTP settings, or into its log while SMTP_HOST is empty, link included.

Projects and files

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/projectsA member with projects.readProjects with counts per tab. status (all, active, completed, or one status), state (in_progress, review, blocked, completed), search, ownerId, dueBefore (YYYY-MM-DD), sort (updated_at, due_date, name, progress), order (asc, desc), page, limit
GET/api/workspaces/:workspaceId/projects/archivedA member with projects.readThe archived projects, with the same filters
GET/api/workspaces/:workspaceId/projects/ownersA member with projects.readThe members who own projects, for the owner filter
GET/api/workspaces/:workspaceId/projects/:projectIdA member with projects.readOne project with its owner, due date, progress and task counts
POST/api/workspaces/:workspaceId/projectsA member with projects.writeCreate a project: translations (en required, ar optional, each name up to 120 characters and description up to 2000), and optionally status, ownerId (a member), dueDate (YYYY-MM-DD) and progress (0 to 100)
PATCH/api/workspaces/:workspaceId/projects/:projectIdA member with projects.writeChange any of those fields
DELETE/api/workspaces/:workspaceId/projects/:projectIdA member with projects.writeArchive a project. Nothing is deleted
POST/api/workspaces/:workspaceId/projects/:projectId/restoreA member with projects.writeBring an archived project back
GET/api/workspaces/:workspaceId/projects/:projectId/activityA member with projects.readThe project's latest 100 events
GET/api/workspaces/:workspaceId/projects/:projectId/filesA member with projects.readThe project's files, newest first
POST/api/workspaces/:workspaceId/projects/:projectId/filesA member with projects.writeAttach a file: multipart file, 10 MB at most. Its size counts toward the plan's storage
GET/api/workspaces/:workspaceId/projects/:projectId/files/:fileId/downloadA member with projects.readThe file's url, name and mime

Creating a project in a past-due workspace whose grace period has ended answers 403.

Tasks and the board

A task is listed and created under its project, and changed by its own id under the workspace.

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/projects/:projectId/tasksA member with projects.readThe board's tasks in column order. assigneeId, priority, search
POST/api/workspaces/:workspaceId/projects/:projectId/tasksA member with projects.writeCreate a task: translations (en required, each title up to 160 characters and description up to 4000), and optionally status (backlog, the default, in_progress, review, done), priority (low, normal, high), assigneeId (a member) and dueDate
PATCH/api/workspaces/:workspaceId/tasks/:taskIdA member with projects.writeChange any of those fields
POST/api/workspaces/:workspaceId/tasks/:taskId/moveA member with projects.writeMove a card: status (the column) and beforeId, the card it lands above, or nothing for the column's end
DELETE/api/workspaces/:workspaceId/tasks/:taskIdA member with projects.writeArchive a task

An assignee who is not a member answers 400 with assignee_not_member. Every change writes an event to the activity trail, such as task.moved with the columns it went from and to.

Overview, activity, notifications and search

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/overviewA member with workspace.readThe overview: projects by status, members and pending invitations, task events per day for the last seven days, the launch checklist, up to three projects due within the period and the latest six events. period (7d, 30d, the default, 90d). Without projects.read the project counts, the chart and the projects due come back zero or empty
GET/api/workspaces/:workspaceId/activityA member with audit.readThe activity trail, with the people in it as actors. action (one action such as task.moved, or a family ending in a dot such as task.), actorId, from and to (YYYY-MM-DD), search, page, limit
GET/api/workspaces/:workspaceId/activity/exportA member with audit.readThe same filters as a CSV file (date, actor, action, subject, details), up to 5,000 rows. Cells that would run as a spreadsheet formula are escaped
GET/api/workspaces/:workspaceId/notificationsA member with workspace.readThe bell: the latest 8 events and how many are newer than the reader's last look
POST/api/workspaces/:workspaceId/notifications/readA member with workspace.readMark everything up to now as read
GET/api/workspaces/:workspaceId/searchA member with workspace.readq (2 to 100 characters): up to five projects, tasks and members each. Projects and tasks appear only for a member with projects.read

The overview's latest events and the bell show the whole trail to a caller with projects.read or audit.read, only the billing. events to one with billing.read and neither of those, and nothing to anyone else.

Billing, its webhook and the test checkout

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/billingA member with billing.readThe subscription: plan, status, price, renewal, a cancellation or downgrade waiting for the renewal, the past-due grace period, usage and any checkout in progress
GET/api/workspaces/:workspaceId/billing/invoicesA member with billing.readThe latest 36 invoices
GET/api/workspaces/:workspaceId/billing/invoices/:invoiceId/download-linkA member with billing.readA signed link to the invoice's PDF, valid for five minutes, built from PUBLIC_API_URL
POST/api/workspaces/:workspaceId/billing/previewA member with billing.manageWhat changing to planId (starter, pro, scale) costs: upgrade or downgrade, when it takes effect, today's charge (chargeTodayCents), the next invoice and any blockers (seats, API requests or storage over the smaller plan's limits). An upgrade of a running Stripe subscription is priced by Stripe and answers chargesCardOnFile: true with a quote that holds that price for 30 minutes; the test checkout prorates the difference over the rest of the period. The current plan answers 400 with same_plan, except on a first payment (a trial, an ended trial or a canceled workspace), where any plan is charged its full price
POST/api/workspaces/:workspaceId/billing/checkoutA member with billing.manageplanId, optionally locale and the preview's quote. An upgrade of a running Stripe subscription is made at once and charged to the payment method on file (upgraded: true), the renewal date unchanged; a refused charge answers 402 with payment_declined, payment_needs_action or payment_method_missing, and an expired or foreign quote 409 with preview_expired. Any other upgrade answers the hosted checkout's url and its session. A downgrade is scheduled for the renewal (scheduled: true), on Stripe through a subscription schedule, or refused with 409 over_plan_limit and the blockers
POST/api/workspaces/:workspaceId/billing/checkout/:sessionId/syncA member with billing.readAfter the return from checkout: read the session back from the provider and answer the session and the summary. The plan changes only once the provider says it is paid
POST/api/workspaces/:workspaceId/billing/keep-planA member with billing.manageTake back a downgrade scheduled for the renewal; answers the summary. Nothing scheduled answers 409 with no_scheduled_change
POST/api/workspaces/:workspaceId/billing/portalA member with billing.manageThe Stripe customer portal's url, for the payment method; url: null for a workspace not billed through Stripe
POST/api/workspaces/:workspaceId/billing/cancelA member with billing.manageCancel at the end of the period. A downgrade scheduled for the renewal is dropped
POST/api/workspaces/:workspaceId/billing/resumeA member with billing.manageKeep a subscription that was set to cancel
PATCH/api/workspaces/:workspaceId/billing/emailA member with billing.manageChange the billing email
GET/api/billing/invoices/downloadAnyone holding the signed link?token=: the invoice as a PDF. A wrong token answers 401 and an expired one 403
POST/api/billing/webhooks/stripeStripeStripe's events, verified with STRIPE_WEBHOOK_SECRET against the Stripe-Signature header; a missing or wrong signature answers 400, and so does every delivery while STRIPE_WEBHOOK_SECRET is empty, with a message naming it. Subscriptions use checkout.session.completed, invoice.paid, invoice.payment_failed and customer.subscription.deleted, each event id applied once. Licence orders arrive on the same route (The licence checkout)
GET/api/billing/simulator/:sessionIdA member with billing.manage in the session's workspaceThe test checkout's session, for the dashboard's own payment page
POST/api/billing/simulator/:sessionId/completeA member with billing.manage in the session's workspaceTest payment: the plan changes as if the provider had confirmed it
POST/api/billing/simulator/:sessionId/cancelA member with billing.manage in the session's workspaceLeave the test checkout without paying
  • Checkout uses Stripe Checkout when STRIPE_SECRET_KEY is set, and the built-in test checkout otherwise, or whenever BILLING_PROVIDER=simulator. The test checkout charges nobody.
  • The return address alone never changes a plan: only the signed webhook, or the session read back on sync, does.
  • Each plan's Stripe price is created on first use, with the lookup key northstack_<plan>_usd_<cents>_month, so nothing is set up in Stripe first.
  • A completed Stripe checkout for a workspace that already has a Stripe subscription cancels the old one, with its unused time credited. invoice.paid stores Stripe's invoice once (invoices are unique per provider invoice id), with its amount and period, moves the workspace's period to the one an invoice starts, and on a renewal applies a downgrade scheduled for it.
  • On the test checkout the API renews each period at its end (a paid invoice, the next 30 days, a scheduled downgrade applied) and ends one set to cancel. A trial that reaches its end becomes past due, with the grace period. Usage counters start again at zero each period. This runs on every billing read and once an hour.
  • Point your Stripe webhook at https://<your API>/api/billing/webhooks/stripe with the events above plus checkout.session.expired.

API keys and the public API

MethodPathWho can call itWhat it does
GET/api/workspaces/:workspaceId/api-keysA member with keys.manageThe workspace's keys: name, prefix, scopes, environment, last use and who made it. Never the secret
POST/api/workspaces/:workspaceId/api-keysA member with keys.managename (2 to 60), scopes (one or more of projects:read, projects:write, tasks:read, tasks:write) and environment (production, the default, or demo). Answers the key with its secret, shown this once; only its SHA-256 hash is kept
DELETE/api/workspaces/:workspaceId/api-keys/:idA member with keys.manageRevoke a key. It stops working at once
GET/api/v1/projectsAn API key with projects:readThe workspace's 100 most recently updated projects
POST/api/v1/projectsAn API key with projects:writeCreate a project, with the same body as the dashboard's
PATCH/api/v1/projects/:projectIdAn API key with projects:writeChange a project, with the same body as the dashboard's
GET/api/v1/projects/:projectId/tasksAn API key with tasks:readOne project's tasks
POST/api/v1/projects/:projectId/tasksAn API key with tasks:writeCreate a task, with the same body as the board's
Terminal
curl http://localhost:8000/api/v1/projects \
  -H "Authorization: Bearer nst_live_…"
  • A production key's secret starts with nst_live_, a demo key's with nst_demo_. A demo key works only while the API runs in demo mode.
  • No key, an unknown or revoked one answers 401; a key without the route's scope answers 403; a suspended workspace refuses the write.
  • Every route but the project list runs as the key's creator: what it creates is recorded as made by them, with the dashboard's validation, activity events and plan limits. It answers 404 once the creator has left the workspace, and the three writes answer 403 once the creator's role no longer holds projects.write.
  • Every call counts toward the plan's API requests, which the billing page and the plan limits read.

Platform overview and organizations

MethodPathWho can call itWhat it does
GET/api/platform/overviewStaff with platform.viewMonthly recurring revenue and its history, the plan mix and the organizations by status. range (30d, 90d, 6m, the default, 12m)
GET/api/platform/overview/exportStaff with reports.exportEvery organization as a CSV file: organization, id, plan, status, seats, mrr_usd, created
GET/api/platform/organizationsStaff with organizations.viewOrganizations. search (name or address), plan (starter, pro, scale), status (trialing, active, past_due, canceling, canceled, suspended), page, limit
GET/api/platform/organizations/:idStaff with organizations.viewOne organization, by its public id
POST/api/platform/organizations/:id/suspendStaff with organizations.managereason (3 to 300 characters). The workspace keeps its reads and refuses every write. A suspended one answers 409
POST/api/platform/organizations/:id/reactivateStaff with organizations.manageLift a suspension

Users, staff, platform roles and settings

MethodPathWho can call itWhat it does
GET/api/usersStaff with users.viewEvery account. search, role (owner, admin, member or billing for a workspace role held anywhere, staff for a platform role, none for accounts in no workspace), page, limit
GET/api/users/deletedStaff with users.viewThe deleted accounts, with the same filters
GET/api/users/statisticStaff with users.viewTotals: accounts, deleted, staff, without a workspace, new in the last 30 days
GET/api/users/deleted/:usernameStaff with users.viewOne deleted account
GET/api/users/:usernameStaff with users.viewOne account
POST/api/usersStaff with users.createCreate an account: first_name, last_name, email, password (8 to 128), optionally phone
PATCH/api/users/:usernameStaff with users.updateChange first_name, last_name, email or phone. A new email is guarded as below
PATCH/api/users/:username/change-passwordStaff with users.updateSet a new password (8 to 128), guarded as below
DELETE/api/users/:usernameStaff with users.deleteMove an account to the deleted list. An account that owns a workspace answers 409 with user_owns_workspace until ownership moves
POST/api/users/deleted/:username/restoreStaff with users.restoreBring a deleted account back
GET/api/adminsStaff with admins.viewEvery account with its platform roles. email, name (first name, last name or username), phone, a row matching any one given, page, page_count
GET/api/admins/statisticsStaff with admins.viewCounts for the staff page
GET/api/admins/:idStaff with admins.viewOne account, by id or username
POST/api/adminsStaff with admins.createAdd a staff account: first_name, last_name, email, password and password_confirmation (6 characters or more), optionally phone, phone_code, profile_picture
PATCH/api/admins/:idStaff with admins.editChange those fields; a new password or email is guarded as below
PATCH/api/admins/:id/rolesStaff with admins.assign_rolesSet an account's platform roles: role_ids. Each role granted or taken away must hold only permissions the caller holds itself, or it answers 403 (role_outranks_caller). Its open dashboard is told at once
DELETE/api/admins/:idStaff with admins.deleteRemove an account. One that owns a workspace answers 409
PATCH/api/admins/profileStaff with admins.editChange the signed-in staff member's own profile
GET/api/rolesStaff with roles.viewPlatform roles. name, guard_name, created_from, created_to, page, page_count
GET/api/roles/statisticsStaff with roles.viewCounts for the roles page
GET/api/roles/selectStaff with roles.viewEvery role's id and name, for a picker
GET/api/roles/permissionsStaff with roles.viewEvery platform permission, grouped by area
GET/api/roles/:idStaff with roles.viewOne role with its permissions
POST/api/rolesStaff with roles.createAdd a role: name. A name in use answers 409
PUT/api/roles/:idStaff with roles.editRename a role: name
POST/api/roles/:id/permissionsStaff with roles.assign_permissionsSet a role's permissions, by name. The caller may give only permissions it holds, and change only a role that holds none it lacks, or it answers 403 (permission_outranks_caller). Everyone holding it is told at once
DELETE/api/roles/:idStaff with roles.deleteDelete a role. One that someone holds answers 400
GET/api/settingsStaff with settings.viewThe platform settings. search, category, page, page_count
GET/api/settings/:keyStaff with settings.viewOne setting
PATCH/api/settings/:keyStaff with settings.editChange one setting: value
DELETE/api/settings/:keyStaff with settings.editDelete one setting

The guard on passwords and emails: a caller other than the account itself may set its password or change its email only when they are a Platform admin or hold every platform permission the account holds; otherwise the API answers 403. The email is guarded too, because a password reset is sent to it.

The guard on roles: nobody can grant, remove or raise a platform role beyond their own permissions, so admins.assign_roles or roles.assign_permissions alone never leads to the Platform admin role.

The 21 platform permissions are platform.view, reports.export, organizations.view, organizations.manage, users.view, users.create, users.update, users.delete, users.restore, admins.view, admins.create, admins.edit, admins.delete, admins.assign_roles, roles.view, roles.create, roles.edit, roles.delete, roles.assign_permissions, settings.view and settings.edit.

Public settings and maintenance mode

MethodPathWho can call itWhat it does
GET/api/public-settingsAnyonemaintenanceMode and trialDays (the platform's trial_days, 14 when unset): what onboarding states as the trial length, and whether the dashboard shows its maintenance screen. Open during maintenance

While the platform setting maintenance_mode is true, every request from anyone who is not platform staff (an account holding platform.view) answers 503, with code: "maintenance_mode" and the message "The platform is closed for maintenance. Please try again shortly." A missing or wrong token gets the same 503. These stay open to everyone:

  • GET /api/health and GET /api/public-settings;
  • POST /api/auth/login and POST /api/auth/logout, where sign-in still refuses anyone who is not staff with the 503 once the credentials are right;
  • POST /api/billing/webhooks/stripe and the signed invoice download;
  • the MCP endpoint, which keeps its own key, and GET /api/demo/status;
  • the website's POST /api/contact and every /api/licenses route, so the contact form and the licence checkout keep working.

Everything else is closed to non-staff, sign-up, password recovery, invitations, onboarding, the public API under /api/v1, uploads and the assistant included. The list is back-end/src/common/constants/maintenance.constants.ts.

The contact form

MethodPathWho can call itWhat it does
POST/api/contactAnyonename (2 to 120), email, topic (licensing, setup, product), message (20 to 4000 characters), clientToken (8 to 64 letters, digits, _ or -) and the hidden field website. Answers { id }
  • A repeated clientToken returns the first message's id instead of storing a second one, so a retry is safe.
  • A filled website field is treated as a bot: the answer looks the same, and nothing is stored.
  • Each address may send five an hour; the next answers 429.
  • Messages are stored in the contact_messages table and forwarded to CONTACT_INBOX when it is set.

The licence checkout

MethodPathWho can call itWhat it does
GET/api/licenses/checkout/optionsAnyoneThe payment methods (card, paypal), the provider taking each (stripe, paypal or simulator), and testMode, true when neither moves real money
POST/api/licenses/checkoutAnyonelicense (single, studio), email, name (2 to 120), method (card, paypal), optionally locale (en, ar), and clientToken. Answers order, the order's id, and url, the page to pay on. A payment page that cannot be opened answers 503 with payment_unavailable
GET/api/licenses/orders/:idAnyone holding the order's idThe order: reference, licence, amount, email, name, method, provider, status (pending, paid, cancelled, failed), test, and licenseKey once it is paid
POST/api/licenses/orders/:id/confirmAnyone holding the order's idThe buyer is back from Stripe or PayPal: ask the provider whether the payment went through, then answer the order
POST/api/licenses/orders/:id/cancelAnyone holding the order's idThe buyer left the payment page without paying
POST/api/licenses/orders/:id/simulateAnyone holding the order's idThe test checkout's Pay or Cancel: outcome (pay, cancel). An order taken by Stripe or PayPal answers 404
  • The price is the server's own, from back-end/src/modules/licenses/constants/license-offers.ts; the browser never sends one.
  • A card goes to Stripe Checkout when STRIPE_SECRET_KEY is set, PayPal to PayPal when PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET are set, and either to the test checkout otherwise. In demo mode a provider with live keys is never offered.
  • An order is paid once, by whichever comes first of Stripe's checkout.session.completed on POST /api/billing/webhooks/stripe, the return's confirm, or the test checkout's Pay. Only that one issues the licence key and mails the receipt. checkout.session.expired marks the order failed.
  • The order's id is a long random string carried only by the buyer's return links. A repeated clientToken answers the first order. Each address may start ten orders an hour.

Uploads and media

MethodPathWho can call itWhat it does
POST/api/helpers/uploadAny signed-in accountStore a picture: multipart file (10 MB at most), path for the folder and for for the size preset (profile, cover, logo, or default). Answers the addresses by size: original, at most 1920 pixels wide, and one resized copy, all JPEG. A file that is not a readable image answers 400

Pictures and project files go to the Cloudflare R2 bucket when its five R2_* variables are set, and to MEDIA_UPLOAD_DIR otherwise, served at PUBLIC_MEDIA_URL followed by /uploads/….

Everything the API serves under /media carries X-Content-Type-Options: nosniff, and a file that is not a raster image, a PDF, plain text, audio or video (an HTML or SVG file, for one) is sent with Content-Disposition: attachment, so it downloads rather than opening as a page on the API's address. Files in an R2 bucket are served by the bucket, with the type they were uploaded with.

Limits per address

RouteLimitVariable
POST /api/auth/login10 failed sign-ins per 15 minutesRATE_LIMIT_LOGIN
POST /api/auth/signup10 per hourNone
POST /api/auth/forgot-password5 per 15 minutesNone
POST /api/auth/reset-password10 per 15 minutesNone
POST /api/contact5 per hourNone
POST /api/licenses/checkout10 per hourNone
POST …/invitations/:id/resendOnce a minute, five times per invitationNone

A request over a limit answers 429, with a sentence naming what was limited and how long to wait, such as "Too many sign-ups from this address. Wait an hour, then try again." The counts live in the API's memory, so a restart clears them. Behind a reverse proxy, set TRUST_PROXY to the number of proxies in front of the API, or every visitor is counted as the proxy's one address.

The AI assistant, its conversations and proposals

Included with your purchase. Sign in to read, or open it in your download.

The assistant's streaming route, its model list, the saved conversations and the task proposals.

MCP server

Included with your purchase. Sign in to read, or open it in your download.

The MCP endpoint for coding agents, how it is authorised and which workspace it reads.

Live permission updates over socket.io

Included with your purchase. Sign in to read, or open it in your download.

The socket.io namespace the dashboard listens on, its event and how a connection is authorised.

Demo mode routes

Included with your purchase. Sign in to read, or open it in your download.

The routes that exist only for a public demo build.

Stuck on a step?

Find a fix before you start over.

Troubleshooting

Cookie Preferences

We use cookies to enhance your browsing experience, analyze site traffic, and personalize content. By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising.