Skip to content

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.

PropertyValue
AudiencePartner integrators · backend engineers
Base URLhttps://<your-tenant>/v1 · JSON over HTTPS
AuthBearer token (JWT) · issued on signup or invite-accept
FlowsSelf-signup (organization admin) · Admin invite (users + staff)
OTPsOrganization OTP (7 days) · Email verification OTP (10 minutes)
Integration~1 sprint for both flows on a typical client

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 = false until 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 — individual for 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.


Endpoint

POST /v1/organizations/signup
Content-Type: application/json

No 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.


---
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

Step 1 · validation — Schema + uniqueness

  • The request body is parsed against the schema
  • The admin email is checked against existing users — 409 if 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.


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.


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/invitations
Authorization: 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/accept
Content-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.


---
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_otp is looked up by email
  • 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 400 with 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 (individual or 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.


OTP typeTTLWhen generatedWhat it unlocks
Organization OTP (onboarding · individual or staff)7 daysAdmin 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 minutesAfter 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?”
StateTokenverifiedCanCannot
Anonymous · before signup or acceptCall public endpoints (signup, accept-invite)Anything else — returns 401
Fresh signup · self-signup or accept-inviteJWT issuedfalseBrowse the platform, read own data, configure profile, accept invitations from other orgsMoney-movement endpoints that check verified = true (top-up, transfer, payment)
Email verified · submitted the 10-minute OTPJWT (same token)trueFull access per role2FA-gated actions if 2FA is active but not yet completed for this session
2FA active · optional, post-onboardingJWTtrueSame 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.


Roles assigned during onboarding

FlowRole assignedHow it is chosen
Self-signuporganization_super_adminAlways — the admin who created the org
Invite — individualindividualAlways — when the inviter leaves role_ids empty
Invite — staffcustom (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:

RoleScope
HRUser management, invitations, profile administration
FinanceAccount 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.


Onboarded asMain balance?Created when
Organization super-admin · self-signupYesDuring the signup call; ready to use once 201 returns
Individual user · invitation acceptedYesDuring the accept call; ready to use once 201 returns
Staff member · invitation accepted with a custom roleNo

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.



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.