The onboarding module covers new organization registration in Moria, admin/member invitations via email + OTP, and the invite-acceptance endpoint for end users. Two controllers are involved: OnboardingOrganizationController at /organizations and InvitationsController at /invitations. Some endpoints are @Public() (signup, accept-invite); the rest require Bearer JWT plus the appropriate role/permission.
Property Value Base URL {HOST}/v1Auth Bearer JWT (header Authorization) or cookie access_token Content-Type application/jsonError envelope { "message": string | string[], "statusCode": number, "error": string }Validation Global ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400 Related modules authentication, organizations, users, acl Document version v1 · 2026-05-20 Audience Internal FE devs (mobile + web)
Two paths into Moria: organization self-signup (the founding admin registers their own organization, initial status PENDING) or invitation (an organization admin invites another admin / individual user via email + OTP). The invitee completes onboarding via POST /invitations/accept without Bearer — authentication is done via the email + OTP combination.
Method Path Auth Summary POST /v1/organizations/signuppublic Self-signup the first admin + organization (Public) POST /v1/invitationsbearer Send invitation OTP to a list of emails (admin / member / individual) POST /v1/invitations/acceptpublic Invitee completes their profile and exchanges the OTP (Public) GET /v1/invitationsbearer List invitations (current organization, or all for MORIA) GET /v1/invitations/:invitation_idbearer Single invitation detail PATCH /v1/invitations/:invitation_idbearer Update invitation fields (email, role, status, organization) DELETE /v1/invitations/:invitation_idbearer Cancel an invitation
Auth notes
Public endpoints set cookie access_token + header Token on successful response — FE can use either. The Token header is exposed via CORS.
Bearer endpoints accept the JWT via Authorization: Bearer <jwt> or via cookie access_token.
Unknown fields in the body will be rejected (400) because of forbidNonWhitelisted: true. Make sure FE doesn’t send fields outside the DTO.
Self-signup: creates the founding admin account along with its organization. Initial admin status is INACTIVE until OTP verification; organization status is PENDING until KYB completes.
public
event · ORGANIZATION_ONBOARDED
Field Type Required Notes first_namestring yes Admin first name last_namestring yes Admin last name middle_namestring optional Middle name emailstring yes Admin login email · must be globally unique passwordstring yes IsStrongPassword — min 8 chars + mixed combinationphone_numberstring yes International format (+62…) namestring yes Organization name logo_idstring optional UUID from file-manager (see file-manager module) organization_emailstring yes Organization contact email organization_phonestring yes Organization contact phone official_registration_numberstring optional Official registration number (NIB / SK) organization_fieldenum INDUSTRY optional Business sector (see INDUSTRY enum) country, province, city, district, subdistrict, village, streetstring yes country/city Full address; some fields optional postal_code, rt, rw, building_number, unit_number, labelstring optional Additional address details address_typeenum AddressType optional Default ORGANIZATION
"email" : " admin@moriafund.com " ,
"password" : " Password@123 " ,
"phone_number" : " +628123456789 " ,
"organization_email" : " ops@moriafund.com " ,
"organization_phone" : " +628123456788 " ,
"province" : " DKI JAKARTA " ,
"street" : " Jl. Sudirman " ,
"district" : " Pademangan " ,
"organization_field" : " finance "
"message" : " admin and organization onboarded successfully, otp sent to admin email. " ,
"id" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"email" : " admin@moriafund.com " ,
"phone_number" : " +628123456789 " ,
"user_type" : " organization " ,
"user_status" : " inactive " ,
"organization_id" : " 660e8400-e29b-41d4-a716-446655440111 " ,
"created_at" : " 2026-05-20T08:30:00.000Z "
The response also sets cookie access_token (httpOnly) and header Token. FE must store one of these for subsequent requests. A separate OTP email is sent for admin account activation (see authentication module for verify-otp).
Status When 400 Bad RequestValidation failed (weak password, invalid email, missing required field, unknown field) 409 ConflictAdmin email or organization name already registered
Creates rows in users (status INACTIVE), organizations (status PENDING), and addresses.
Issues an activation OTP via email to the admin’s address.
Creates the default ACL role for the organization (organization_admin).
Emits BusinessEvent ORGANIZATION_ONBOARDED (impact HIGH).
Send invitation email + OTP to a list of emails. For each email, the specified role determines the kind of user being created (individual vs admin/member of an organization). Individual and admin emails are processed via two different internal paths.
bearer
MORIA, ORGANIZATION, INDIVIDUAL
invite-individual-user, invite-organization-admin, invite-moria-admin
event · USERS_INVITED
Field Type Required Notes emailsstring[] yes List of recipient emails. Each entry must be a valid IsEmail. role_idsstring[] (UUID) yes except for individuals Length MUST match emails. Roles are used for ACL lookup. For a role named individual, the internal path used is different.
"emails" : [ " amal@example.com " , " siti@example.com " ],
" 03be5259-f281-478e-a8d0-e7e825e525f2 " ,
" 03be5259-f281-478e-a8d0-e7e825e525f3 "
"message" : " Organization otp sent successfully to emails "
In non-production environments, the response also includes data.individual and data.admin with a list of { email, otp } for QA convenience. In production these fields are not returned — FE must not rely on them.
Status When 400 Bad Requestrole_ids.length !== emails.length, or an invalid email401 UnauthorizedNo Bearer or cookie token 403 ForbiddenCaller’s role doesn’t hold the required permission
The invitee completes their profile and exchanges email + OTP for an active account. Endpoint is @Public() — FE submits the completed form with the OTP received via email.
public
event · INDIVIDUAL_USER_ONBOARDED
Field Type Required Notes first_name, last_namestring yes Basic identity middle_namestring optional emailstring yes Must match the email that received the OTP organization_otpstring yes OTP from the invitation email passwordstring yes IsStrongPasswordid_card_numberstring optional KTP number (16 digits) educationenum Education optional bachelor, master, etc.mother_name, relativesstring optional Additional KYC data phone_numberstring optional International format purpose, source_of_income, montly_incomestring optional Financial profile genderenum Gender optional male / femaledate_of_birthdate optional ISO 8601 (YYYY-MM-DD) place_of_birthstring optional religionenum Religion optional marital_statusenum MaritalStatus optional profile_imagestring optional Profile URL (see file-manager) country, province, city, district, subdistrict, village, street, postal_code, rt, rw, building_number, unit_number, labelstring optional Residential address address_typeenum AddressType optional Default INDIVIDUAL
"email" : " amal@example.com " ,
"organization_otp" : " 234567 " ,
"password" : " Strong@8Password " ,
"id_card_number" : " 3208180302730003 " ,
"phone_number" : " +628156489101 " ,
"date_of_birth" : " 1990-01-01 " ,
"marital_status" : " single " ,
"province" : " DKI JAKARTA " ,
"message" : " User Onboarded Successfully " ,
"id" : " 770e8400-e29b-41d4-a716-446655440222 " ,
"email" : " amal@example.com " ,
"phone_number" : " +628156489101 " ,
"user_type" : " individual " ,
"organization_id" : " 660e8400-e29b-41d4-a716-446655440111 " ,
"created_at" : " 2026-05-20T09:15:00.000Z "
Status When 400 Bad RequestOTP mismatch / expired, validation error, weak password 409 ConflictEmail or identity already in use by another user
Creates a new user (status ACTIVE), address, and links them to the inviting organization.
Updates invitation.status to ACCEPTED.
Sets cookie access_token (FE can proceed immediately).
List invitations scoped to the caller’s organization (or all organizations if the caller is MORIA). Supports standard pagination and asc/desc ordering by created_at.
bearer
MORIA, ORGANIZATION
event · FETCH_ALL_INVITES
Param Type Default Notes pagenumber 1Page number limitnumber 10Records per page order'asc' | 'desc'descOrder by created_at organization_idstring (UUID) optional Only allowed for MORIA users — filters to a specific organization. For ORGANIZATION role, the server always uses the caller’s own org (parameter is overridden).
"message" : " All invited admins fetched successfully " ,
"id" : " 880e8400-e29b-41d4-a716-446655440333 " ,
"email" : " amal@example.com " ,
"user_type" : " individual " ,
"organization_id" : " 660e8400-e29b-41d4-a716-446655440111 " ,
"role_id" : " 03be5259-f281-478e-a8d0-e7e825e525f2 " ,
"created_by" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"updated_by" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"created_at" : " 2026-05-20T08:45:00.000Z " ,
"updated_at" : " 2026-05-20T08:45:00.000Z "
Status When 401 UnauthorizedBearer/cookie invalid 403 ForbiddenRole is not MORIA/ORGANIZATION
Single invitation detail. The ID must be a UUID v4 — validated via ParseUUIDPipe.
bearer
MORIA, ORGANIZATION
event · FETCH_AN_INVITE
Param Type Notes invitation_idUUID Invitation ID
"message" : " invitation fetched successfully " ,
"id" : " 880e8400-e29b-41d4-a716-446655440333 " ,
"email" : " amal@example.com " ,
"user_type" : " individual " ,
"organization_id" : " 660e8400-e29b-41d4-a716-446655440111 " ,
"role_id" : " 03be5259-f281-478e-a8d0-e7e825e525f2 " ,
"created_by" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"updated_by" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"created_at" : " 2026-05-20T08:45:00.000Z " ,
"updated_at" : " 2026-05-20T08:45:00.000Z "
Status When 400 Bad Requestinvitation_id is not a UUID401 UnauthorizedBearer/cookie invalid 404 Not FoundInvitation not found
Update invitation fields. All fields optional — send only what changed.
bearer
MORIA, ORGANIZATION
event · UPDATE_AN_INVITE
Field Type Notes emailstring New email user_typeenum UserType moria, organization, individualstatusenum InvitationStatus invited, accepted, cancelled, expiredrole_idUUID Move to another role organization_idUUID Move to another organization
"message" : " invitation updated successfully " ,
"invitation" : { "..." : " see shape under GET /:invitation_id " }
Status When 400 Bad RequestValidation failed (invalid UUID, unknown enum) 401 UnauthorizedBearer/cookie invalid 403 ForbiddenRole lacks access 404 Not FoundInvitation not found
Cancel an invitation. Soft-cancel — the record remains but status = 'cancelled' and deleted_by is set.
bearer
MORIA, ORGANIZATION
event · CANCEL_AN_INVITE
Param Type Notes invitation_idUUID ID of the invitation to cancel
"message" : " invitation canceled successfully " ,
"invitation" : { "..." : " same shape as GET /:invitation_id, status now 'cancelled' " }
Status When 400 Bad Requestinvitation_id is not a UUID401 UnauthorizedBearer/cookie invalid 403 ForbiddenRole lacks access 404 Not FoundInvitation not found
moria — platform superadmin
organization — organization admin/member
individual — end user
invited — OTP sent, waiting for accept
accepted — user has onboarded
cancelled — cancelled by admin
expired — OTP expired
"message" : " Role IDs and emails length mismatch " ,
message may be a string or an array of strings (especially for multi-field validation errors).
400 body/query/param validation
401 missing / expired token
403 role/permission mismatch
404 resource not found
409 resource already exists
500 internal — show generic toast in FE