User Onboarding
Moria Fund offers two entry paths into the platform: self-signup for organization admins (an anonymous public endpoint) and admin invite for individual users and staff (a token-based invite plus a public accept). This guide is aimed at backend engineers integrating the API — every endpoint, every request and response shape, every OTP, and the auth-state progression your client needs to handle.
| Property | Value |
|---|---|
| Audience | Partner integrators · backend engineers |
| Base URL | https://<your-tenant>/v1 · JSON over HTTPS |
| Auth | Bearer token (JWT) · issued on signup or invite-accept |
| Flows | Self-signup (organization admin) · Admin invite (users + staff) |
| OTPs | Organization OTP (7 days) · Email verification OTP (10 minutes) |
| Integration | ~1 sprint for both flows on a typical client |
Two flows, one platform
Section titled “Two flows, one platform”A · Self-signup (organization admin)
- Anonymous public endpoint — no token
- Creates the organization and its super-admin user together in a single call
- Provisions an organization pool account behind the scenes
- Creates a main balance for the admin
- Sends a 10-minute email verification OTP
- Returns a JWT immediately — the admin is already signed in, but
verified = falseuntil they submit the OTP
B · Admin invite (individuals + staff)
- Two endpoints: the admin sends the email (token-protected), the invitee submits their profile (anonymous)
- Uses a separate organization OTP (7-day TTL) as the binding mechanism between invite and accept
- The invitee submits their KYC profile and a full address on accept
- The role comes from the invitation —
individualfor users, custom (HR, Finance, …) for staff - Individuals receive a main balance; staff do not (they operate on the organization pool)
- Then the same 10-minute email verification OTP is sent
Same. Both flows produce user, address, and security records; both end with a JWT and an email verification OTP. Different. Self-signup creates the organization; invite attaches a new user to an existing organization. The organization OTP is only relevant to the invite flow.
Self-signup — endpoint and request
Section titled “Self-signup — endpoint and request”Endpoint
POST /v1/organizations/signupContent-Type: application/jsonNo authorization header — this endpoint is public. A single call creates the organization and its super-admin user at the same time.
Auth on success: the response sets a JWT cookie and returns the same token in the body. Subsequent calls are sent with Authorization: Bearer <token>.
Request body
{ "first_name": "Alex", "middle_name": "Sari", "last_name": "Putri", "email": "alex@partnerorg.com", "password": "********", "phone_number": "+628120000000",
"name": "Partner Org Pte Ltd", "organization_email": "ops@partnerorg.com", "organization_phone": "+622150000000", "official_registration_number": "0123456789",
"country": "ID", "city": "Jakarta", "province": "DKI Jakarta", "street": "Jl. Sudirman No. 1", "postal_code": "10220", "address_type": "ORGANIZATION"}The top half of the body identifies the admin user; the bottom half identifies the organization and its registered address. middle_name is optional; the rest are required. The password is stored as a hash — never plaintext — and email addresses are deduplicated, so reusing an email returns 409.
Self-signup — full sequence
Section titled “Self-signup — full sequence”---
config:
sequence:
actorMargin: 360
width: 200
messageMargin: 50
boxMargin: 16
noteMargin: 14
---
sequenceDiagram
autonumber
actor U as Admin (anonymous)
participant API as Moria API
participant MAIL as User's Email
U->>API: POST /v1/organizations/signup
Note over API: Validates the request body, then atomically creates:<br/>• organization (status: PENDING)<br/>• super-admin user (verified: false)<br/>• address record<br/>• organization's default pool account<br/>• admin's main balance account<br/>• per-organization default roles (HR · Finance)
API->>MAIL: Sends a 6-digit verification OTP (TTL 10 minutes)
Note over API: Issues JWT (cookie + header)
API-->>U: 201 Created
Note over U,MAIL: Out of band — admin reads the email
MAIL-->>U: 6-digit OTP
U->>API: POST /v1/verify-email · { otp }
API-->>U: 200 OK · verified = true
What happens between request and response
Section titled “What happens between request and response”Step 1 · validation — Schema + uniqueness
- The request body is parsed against the schema
- The admin email is checked against existing users —
409if already in use - The organization email and name are checked for collisions
- The password is hashed before storage
Step 2 · provision — Organization + admin together
- Organization is created (
status: PENDING) - Super-admin user is created (
verified: false) - Address is attached to the organization
- Organization pool account is created
- Default roles are seeded inside the organization (HR · Finance)
- All of the above runs atomically — either the whole signup succeeds or none of it does
Step 3 · finalization — Balance · OTP · JWT
- Admin’s main balance is created
- A 10-minute email verification OTP is generated and sent to the admin’s email
- JWT is issued (set as a cookie and returned in the response body)
- Response is returned with HTTP 201
Your client only has to wait for the 201. By the time the response arrives, the admin is signed in, the organization exists, and the verification email is on its way. The JWT is valid immediately — verified = false on the user, but most read endpoints will work right away.
Success response shape
Section titled “Success response shape”HTTP 201 Created
{ "data": { "user": { "id": "1f8a-...-c401", "first_name": "Alex", "last_name": "Putri", "email": "alex@partnerorg.com", "phone_number": "+628120000000", "user_type": "ORGANIZATION", "verified": false, "organization_id": "9b2d-...-7e10", "created_at": "2026-05-18T03:12:44Z" }, "organization": { "id": "9b2d-...-7e10", "name": "Partner Org Pte Ltd", "organization_email": "ops@partnerorg.com", "status": "PENDING", "created_at": "2026-05-18T03:12:44Z" }, "token": "eyJhbGciOi..." }}The same JWT is also set as an HTTP-only cookie on the response. Pick whichever fits your client — cookie for browsers, bearer header for mobile or server-to-server. organization.status stays PENDING until a Moria admin activates it; the signed-in admin can still configure the organization profile and invite users during this period.
Failure modes to handle
Section titled “Failure modes to handle”The standard error envelope for all failures: { "statusCode": 409, "message": "Email already registered", "error": "Conflict" }.
Admin invite — two endpoints, two actors
Section titled “Admin invite — two endpoints, two actors”Step 1 · Admin sends the invite
POST /v1/invitationsAuthorization: Bearer <token>Content-Type: application/json{ "emails": ["new.staff@partnerorg.com"], "role_ids": ["3a1c-...-9f02"]}The caller must have invite-individual-user or invite-organization-admin. role_ids is optional; if supplied, its length must match emails and each id must reference a role inside the caller’s organization.
Step 2 · Invitee accepts
POST /v1/invitations/acceptContent-Type: application/json{ "first_name": "Rina", "last_name": "Hartono", "email": "new.staff@partnerorg.com", "password": "********", "phone_number": "+628190000000", "country": "ID", "city": "Bandung", "province": "Jawa Barat", "street": "...", "postal_code": "40115", "address_type": "HOME", "organization_otp": "482913"}Anonymous endpoint. On success: the response sets a JWT cookie and returns the token; an email verification OTP is sent to email.
The invitation sits at INVITED until the invitee accepts. The organization OTP, scoped per email per organization, is what binds the invitee’s new account to the correct organization at accept time. Without a valid OTP, the accept endpoint has no way to know which organization to attach to and will return 400.
Admin invite — full sequence
Section titled “Admin invite — full sequence”---
config:
sequence:
actorMargin: 360
width: 200
messageMargin: 50
boxMargin: 16
noteMargin: 14
---
sequenceDiagram
autonumber
actor A as Organization Admin
participant API as Moria API
participant MAIL as Invite Email
actor I as Invitee
Note over A,API: Part A · Admin sends invite
A->>API: POST /v1/invitations · { emails, role_ids? }
Note over API: Validates permissions, then creates:<br/>• one invitation per email (status: INVITED)<br/>• one 6-digit organization OTP per email (TTL 7 days)
API->>MAIL: Sends invite email with OTP
API-->>A: 201 Created
Note over I,MAIL: Out of band — invitee reads the email
MAIL-->>I: 6-digit organization OTP
Note over I,API: Part B · Invitee accepts
I->>API: POST /v1/invitations/accept · { profile, address, organization_otp }
Note over API: Validates OTP, determines organization,<br/>then atomically creates:<br/>• user, address, and security record (verified: false)<br/>• role attachment from the invitation<br/>• marks invitation ACCEPTED and burns OTP<br/>• main balance account (individuals only — staff do not get one)
API->>MAIL: Sends a 6-digit verification OTP (TTL 10 minutes)
Note over API: Issues JWT (cookie + header)
API-->>I: 201 Created
Acceptance — between request and response
Section titled “Acceptance — between request and response”Step 1 · validation — OTP must pass the check
- The submitted
organization_otpis looked up byemail - The OTP must match, be unused, and be no older than 7 days
- The organization that owns the OTP is determined — this is the organization the user will join
- All failures here return
400with no diagnostic leakage
Step 2 · provision — User + address + security
- User is created, attached to the determined organization
- Address is attached to the user
- Security record is created (
verified: false) - All of the above runs atomically
Step 3 · finalization — Role · balance · OTP · JWT
- Role is attached based on the invitation (
individualor custom) - Main balance is created — individuals only
- Invitation is marked
ACCEPTED; the organization OTP is marked used - A 10-minute email verification OTP is sent
- JWT is issued, response is returned with HTTP 201
The accept endpoint can return 400 for OTP failures, 409 if the email is already a Moria user, and 500 on rare provisioning issues. Once the 201 arrives, the invitee is signed in and the verification email is on its way.
Two OTP types — when each is sent
Section titled “Two OTP types — when each is sent”| OTP type | TTL | When generated | What it unlocks |
|---|---|---|---|
| Organization OTP (onboarding · individual or staff) | 7 days | Admin sends POST /v1/invitations. One OTP is created per email on the request. | The invitee sends the OTP back on POST /v1/invitations/accept — this is what binds the new user to the correct organization. |
| Email Verification OTP (both flows) | 10 minutes | After signup or after invite-accept · once the user record exists · sent via email. | The user sends the OTP to the verify-email endpoint, which flips the verified flag on their account from false to true. |
Format. Both OTPs are 6-digit numeric strings (e.g. "482913"). The platform stores them hashed; only the email recipient sees the plaintext.
Single use · constant-time check. Each OTP is single use. Once accepted, it is marked used and cannot be replayed. All validation is constant-time — a wrong OTP and an expired OTP take the same amount of time to fail, and the response message does not distinguish between them.
Your client should anticipate 400 Bad Request for any OTP failure (wrong, expired, already used). Do not try to enumerate the cases — the response intentionally does not tell you, by design.
When is a user “considered” authenticated?
Section titled “When is a user “considered” authenticated?”| State | Token | verified | Can | Cannot |
|---|---|---|---|---|
| Anonymous · before signup or accept | — | — | Call public endpoints (signup, accept-invite) | Anything else — returns 401 |
| Fresh signup · self-signup or accept-invite | JWT issued | false | Browse the platform, read own data, configure profile, accept invitations from other orgs | Money-movement endpoints that check verified = true (top-up, transfer, payment) |
| Email verified · submitted the 10-minute OTP | JWT (same token) | true | Full access per role | 2FA-gated actions if 2FA is active but not yet completed for this session |
| 2FA active · optional, post-onboarding | JWT | true | Same as above plus sensitive actions confirmed by 2FA | — |
The JWT is issued before email verification — your client can authenticate calls immediately after signup. The verified flag on the user object starts as false and flips to true once the 10-minute OTP is submitted to the verify-email endpoint. 2FA is not part of onboarding; it is an opt-in setting the user can enable later from their account preferences.
Who gets which role
Section titled “Who gets which role”Roles assigned during onboarding
| Flow | Role assigned | How it is chosen |
|---|---|---|
| Self-signup | organization_super_admin | Always — the admin who created the org |
| Invite — individual | individual | Always — when the inviter leaves role_ids empty |
| Invite — staff | custom (HR, Finance, …) | From the role_ids entry the inviting admin sets on the invitation |
The role is attached to the user just before the JWT is issued. From that point on, every authenticated call is checked against the role’s permissions.
Per-organization roles seeded at signup
When self-signup creates an organization, the platform also seeds two ready-to-use roles inside that organization that the super-admin can assign immediately on subsequent invitations:
| Role | Scope |
|---|---|
HR | User management, invitations, profile administration |
Finance | Account oversight, balance read, payment operations |
Both come pre-populated with the permissions an organization typically needs for those functions. The super-admin can edit them or replace them via the roles endpoint, then reference the role id when sending an invitation.
Who gets a main balance, and when
Section titled “Who gets a main balance, and when”| Onboarded as | Main balance? | Created when |
|---|---|---|
| Organization super-admin · self-signup | Yes | During the signup call; ready to use once 201 returns |
| Individual user · invitation accepted | Yes | During the accept call; ready to use once 201 returns |
| Staff member · invitation accepted with a custom role | No | — |
Your client can use the standard accounts endpoint (GET /v1/accounts) to list the balances the signed-in user can operate on. Individuals will see their main balance; organization admins will see the organization pool and their own main balance; staff will only see the organization pool.
Things to watch out for
Section titled “Things to watch out for”Build with confidence
Section titled “Build with confidence”Onboarding is a deliberately small surface — two flows, three endpoints, two OTP types, one JWT lifecycle. Once your client knows how to handle the verified = false intermediate state and the standard error envelope, the rest of the platform is open to integrate against.