Skip to content

Onboarding

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.

PropertyValue
Base URL{HOST}/v1
AuthBearer JWT (header Authorization) or cookie access_token
Content-Typeapplication/json
Error envelope{ "message": string | string[], "statusCode": number, "error": string }
ValidationGlobal ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400
Related modulesauthentication, organizations, users, acl
Document versionv1 · 2026-05-20
AudienceInternal 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.

MethodPathAuthSummary
POST/v1/organizations/signuppublicSelf-signup the first admin + organization (Public)
POST/v1/invitationsbearerSend invitation OTP to a list of emails (admin / member / individual)
POST/v1/invitations/acceptpublicInvitee completes their profile and exchanges the OTP (Public)
GET/v1/invitationsbearerList invitations (current organization, or all for MORIA)
GET/v1/invitations/:invitation_idbearerSingle invitation detail
PATCH/v1/invitations/:invitation_idbearerUpdate invitation fields (email, role, status, organization)
DELETE/v1/invitations/:invitation_idbearerCancel an invitation

POST /v1/organizations/signup public

Section titled “POST /v1/organizations/signup ”

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
FieldTypeRequiredNotes
first_namestringyesAdmin first name
last_namestringyesAdmin last name
middle_namestringoptionalMiddle name
emailstringyesAdmin login email · must be globally unique
passwordstringyesIsStrongPassword — min 8 chars + mixed combination
phone_numberstringyesInternational format (+62…)
namestringyesOrganization name
logo_idstringoptionalUUID from file-manager (see file-manager module)
organization_emailstringyesOrganization contact email
organization_phonestringyesOrganization contact phone
official_registration_numberstringoptionalOfficial registration number (NIB / SK)
organization_fieldenum INDUSTRYoptionalBusiness sector (see INDUSTRY enum)
country, province, city, district, subdistrict, village, streetstringyes country/cityFull address; some fields optional
postal_code, rt, rw, building_number, unit_number, labelstringoptionalAdditional address details
address_typeenum AddressTypeoptionalDefault ORGANIZATION
{
"first_name": "Anas",
"last_name": "Malik",
"email": "admin@moriafund.com",
"password": "Password@123",
"phone_number": "+628123456789",
"name": "Moria Fund",
"organization_email": "ops@moriafund.com",
"organization_phone": "+628123456788",
"country": "Indonesia",
"city": "Jakarta",
"province": "DKI JAKARTA",
"street": "Jl. Sudirman",
"district": "Pademangan",
"organization_field": "finance"
}
{
"status": "success",
"statusCode": 201,
"message": "admin and organization onboarded successfully, otp sent to admin email.",
"data": {
"organizationAdmin": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"first_name": "Anas",
"last_name": "Malik",
"middle_name": null,
"email": "admin@moriafund.com",
"phone_number": "+628123456789",
"user_type": "organization",
"user_status": "inactive",
"profile_image": null,
"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).

StatusWhen
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
FieldTypeRequiredNotes
emailsstring[]yesList of recipient emails. Each entry must be a valid IsEmail.
role_idsstring[] (UUID)yes except for individualsLength 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"],
"role_ids": [
"03be5259-f281-478e-a8d0-e7e825e525f2",
"03be5259-f281-478e-a8d0-e7e825e525f3"
]
}
{
"status": "success",
"statusCode": 201,
"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.

StatusWhen
400 Bad Requestrole_ids.length !== emails.length, or an invalid email
401 UnauthorizedNo Bearer or cookie token
403 ForbiddenCaller’s role doesn’t hold the required permission

POST /v1/invitations/accept public

Section titled “POST /v1/invitations/accept ”

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

Request body — OnboardUserDto (main fields)

Section titled “Request body — OnboardUserDto (main fields)”
FieldTypeRequiredNotes
first_name, last_namestringyesBasic identity
middle_namestringoptional
emailstringyesMust match the email that received the OTP
organization_otpstringyesOTP from the invitation email
passwordstringyesIsStrongPassword
id_card_numberstringoptionalKTP number (16 digits)
educationenum Educationoptionalbachelor, master, etc.
mother_name, relativesstringoptionalAdditional KYC data
phone_numberstringoptionalInternational format
purpose, source_of_income, montly_incomestringoptionalFinancial profile
genderenum Genderoptionalmale / female
date_of_birthdateoptionalISO 8601 (YYYY-MM-DD)
place_of_birthstringoptional
religionenum Religionoptional
marital_statusenum MaritalStatusoptional
profile_imagestringoptionalProfile URL (see file-manager)
country, province, city, district, subdistrict, village, street, postal_code, rt, rw, building_number, unit_number, labelstringoptionalResidential address
address_typeenum AddressTypeoptionalDefault INDIVIDUAL
{
"first_name": "Aisha",
"last_name": "Putri",
"email": "amal@example.com",
"organization_otp": "234567",
"password": "Strong@8Password",
"id_card_number": "3208180302730003",
"phone_number": "+628156489101",
"gender": "male",
"date_of_birth": "1990-01-01",
"religion": "islam",
"marital_status": "single",
"province": "DKI JAKARTA",
"city": "Jakarta",
"country": "Indonesia"
}
{
"status": "success",
"statusCode": 201,
"message": "User Onboarded Successfully",
"data": {
"user": {
"id": "770e8400-e29b-41d4-a716-446655440222",
"first_name": "Aisha",
"last_name": "Putri",
"middle_name": null,
"email": "amal@example.com",
"phone_number": "+628156489101",
"user_type": "individual",
"user_status": "active",
"profile_image": null,
"organization_id": "660e8400-e29b-41d4-a716-446655440111",
"created_at": "2026-05-20T09:15:00.000Z"
}
}
}
StatusWhen
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
ParamTypeDefaultNotes
pagenumber1Page number
limitnumber10Records per page
order'asc' | 'desc'descOrder by created_at
organization_idstring (UUID)optionalOnly allowed for MORIA users — filters to a specific organization. For ORGANIZATION role, the server always uses the caller’s own org (parameter is overridden).
{
"status": "success",
"statusCode": 200,
"message": "All invited admins fetched successfully",
"data": {
"limit": 10,
"count": 24,
"currentPage": 1,
"totalPages": 3,
"invitations": [
{
"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",
"status": "invited",
"created_by": "550e8400-e29b-41d4-a716-446655440000",
"updated_by": "550e8400-e29b-41d4-a716-446655440000",
"deleted_by": null,
"created_at": "2026-05-20T08:45:00.000Z",
"updated_at": "2026-05-20T08:45:00.000Z"
}
]
}
}
StatusWhen
401 UnauthorizedBearer/cookie invalid
403 ForbiddenRole is not MORIA/ORGANIZATION

GET /v1/invitations/:invitation_id bearer

Section titled “GET /v1/invitations/:invitation_id ”

Single invitation detail. The ID must be a UUID v4 — validated via ParseUUIDPipe.

bearer MORIA, ORGANIZATION event · FETCH_AN_INVITE
ParamTypeNotes
invitation_idUUIDInvitation ID
{
"status": "success",
"statusCode": 200,
"message": "invitation fetched successfully",
"data": {
"invitation": {
"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",
"status": "invited",
"created_by": "550e8400-e29b-41d4-a716-446655440000",
"updated_by": "550e8400-e29b-41d4-a716-446655440000",
"deleted_by": null,
"created_at": "2026-05-20T08:45:00.000Z",
"updated_at": "2026-05-20T08:45:00.000Z"
}
}
}
StatusWhen
400 Bad Requestinvitation_id is not a UUID
401 UnauthorizedBearer/cookie invalid
404 Not FoundInvitation not found

PATCH /v1/invitations/:invitation_id bearer

Section titled “PATCH /v1/invitations/:invitation_id ”

Update invitation fields. All fields optional — send only what changed.

bearer MORIA, ORGANIZATION event · UPDATE_AN_INVITE
FieldTypeNotes
emailstringNew email
user_typeenum UserTypemoria, organization, individual
statusenum InvitationStatusinvited, accepted, cancelled, expired
role_idUUIDMove to another role
organization_idUUIDMove to another organization
{
"status": "expired"
}
{
"status": "success",
"statusCode": 200,
"message": "invitation updated successfully",
"data": {
"invitation": { "...": "see shape under GET /:invitation_id" }
}
}
StatusWhen
400 Bad RequestValidation failed (invalid UUID, unknown enum)
401 UnauthorizedBearer/cookie invalid
403 ForbiddenRole lacks access
404 Not FoundInvitation not found

DELETE /v1/invitations/:invitation_id bearer

Section titled “DELETE /v1/invitations/:invitation_id ”

Cancel an invitation. Soft-cancel — the record remains but status = 'cancelled' and deleted_by is set.

bearer MORIA, ORGANIZATION event · CANCEL_AN_INVITE
ParamTypeNotes
invitation_idUUIDID of the invitation to cancel
{
"status": "success",
"statusCode": 200,
"message": "invitation canceled successfully",
"data": {
"invitation": { "...": "same shape as GET /:invitation_id, status now 'cancelled'" }
}
}
StatusWhen
400 Bad Requestinvitation_id is not a UUID
401 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",
"statusCode": 400,
"error": "Bad Request"
}

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