5. User profile management
5.1 Portal endpoints and validation
Both roles may manage their own profile. Responses use the Portal envelope and Cache-Control: no-store.
| Method and path | Body/query | Result |
|---|---|---|
GET /portal/me |
None | 200, current user and merchant identity |
PATCH /portal/me |
Any of first_name, last_name, email; at least one |
200, updated self-profile; email may be pending |
POST /portal/me/email/verify |
otp (six decimal digits as a string) |
200, verified profile |
POST /portal/me/email/resend |
None; unexpected fields rejected | 200, sent message and data=null |
PUT /portal/me/password |
current_password, password, password_confirmation |
200, revoke user tokens and require login |
Implemented profile validation: names are nonempty strings up to 50 characters; email is a valid address up to 255 characters. PATCH requires at least one allowed field and rejects unexpected fields, including role, user selector, merchant ownership, activation, and verification status. A mismatched merchant selector is rejected by merchant context with 403; other field validation failures return 422 VALIDATION_ERROR. Omitted fields remain unchanged. Names are trimmed; replacement email is trimmed and lowercased.
Changing email retains the current login email until verification, then changes it atomically. The replacement must be unique against other current emails, pending emails, and usernames that could collide with email-based login; uniqueness is rechecked at verification. Duplicate addresses return 422 with an email field error and do not change names or consume verification. Username remains unchanged. Successful email verification preserves existing PATs; password changes revoke them.
A names-only PATCH preserves any pending email. Sending the current email explicitly cancels the pending change. Sending a different replacement issues fresh proofs and invalidates previous ones. Pending records, including expired records, continue reserving their email until completed, replaced, or cancelled. Mail is sent after the database transaction; delivery failure can leave a pending change saved. Read profile state and use resend to recover.
PATCH is limited to 3/minute/user, resend to 3/minute/user, and OTP verification to 10/minute/user, using separate named limiters in addition to the API-wide limit. Requests rejected by validation also consume the request limit. 429 RATE_LIMITED caused by request throttling includes Retry-After.
Implemented password validation is described in section 3.4. PUT /api/v1/portal/me/password permits 6 requests/minute per authenticated user in addition to the API-wide throttle. It accepts no user selector. Incorrect current password, invalid length/composition, or mismatched confirmation returns 422 with error_code=VALIDATION_ERROR and field errors. Invalid access returns 401; ineligible membership/application/scope returns 403; throttling returns 429. Validation failure leaves the password and tokens unchanged.
Request example (replace placeholders with actual passwords):
{
"current_password": "<current_password>",
"password": "<new_password>",
"password_confirmation": "<new_password>"
}
Successful response:
{
"success": true,
"message": "Password updated. Please log in again.",
"data": {
"reauthentication_required": true
}
}
Clear the Portal session and obtain a new PAT through normal login after password-change success.
GET /portal/me data object (also returned under data after PATCH and OTP verification):
{
"id": 42,
"reference_id": "USR_EXAMPLE",
"first_name": "Alex",
"last_name": "Reyes",
"username": "alex.reyes",
"email": "merchant.admin@example.com",
"pending_email": null,
"pending_email_expires_at": null,
"email_verified_at": "2026-09-01T00:00:00Z",
"role": "administrator",
"is_active": true,
"merchant": { "id": 7, "reference_id": "MER_EXAMPLE", "name": "Example Merchant" }
}
pending_email and its ISO 8601 expiry are null when no replacement is pending. Password hashes, OTP hashes, attempt counters, and credential secrets are never included in this resource.
5.2 Email verification and Portal redirect
Example PATCH body:
{
"email": "replacement@example.com"
}
The new address receives the existing verification email containing a six-digit OTP and an expiring signed DLT link. Submit the OTP as a string to POST /api/v1/portal/me/email/verify:
{
"otp": "123456"
}
OTP and link expire after 15 minutes. Incorrect OTP returns 422 VALIDATION_ERROR and increments a persisted counter; after five incorrect attempts, subsequent verification returns 429 RATE_LIMITED with a message requesting a new code. Both OTP and link are blocked by that counter. Missing/expired pending verification returns 422. Resend creates a different OTP, resets the counter and expiry, and invalidates the prior signed link. Completed proofs cannot be reused.
The email link targets GET /portal/profile/email/verify/{verification} on the DLT web host, outside /api/v1. It requires Laravel's valid expiring signature plus a proof bound to the current pending record; it does not require a PAT or admin session. It rechecks active merchant membership, pending-record ownership/expiry/attempts, and email uniqueness. A signed link cannot update arbitrary fields or verify another replacement.
Successful link verification returns 302 to APP_MERCHANT_URL?email_verification=verified; a valid signature with a stale/consumed/ineligible/conflicting proof redirects with email_verification=invalid. Invalid, tampered, or expired signatures are rejected with 403 before redirect. The redirect includes no email, OTP, signature, or token, and uses Cache-Control: no-store and Referrer-Policy: no-referrer.
The destination is the DLT Merchant Portal URL configured as APP_MERCHANT_URL, preserving its base path. This setting refers to DLT's Portal, not the merchant business's website. Handle email_verification as UI feedback and fetch the profile after redirect. Missing/invalid destination configuration returns 503 and prevents new email changes.
5.3 Request validation reference
These tables reflect the current implementation’s request rules. See input conventions for required/optional/null behavior and error formats.
Validation: GET /api/v1/portal/me
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
| (none) | Body/query | No documented inputs | No payload is required; see behavior below. |
Send no request body. No body/query fields are validated or used to select another user; identity comes from the PAT.
Validation: PATCH /api/v1/portal/me
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
first_name |
JSON body | Optional; required if supplied | Nonempty string, maximum 50 characters; trimmed. |
last_name |
JSON body | Optional; required if supplied | Nonempty string, maximum 50 characters; trimmed. |
email |
JSON body | Optional; required if supplied | RFC-valid email string, maximum 255 characters; trimmed and lowercased. Must be available against other users’ current emails, pending emails and usernames; rechecked at verification. |
Supply at least one of the three fields; {} is invalid. Omitted fields remain unchanged. Null/empty values are invalid. Unlisted payload/query fields are rejected with 422 VALIDATION_ERROR. A conflicting merchant selector may instead be rejected earlier with 403 MERCHANT_SCOPE_MISMATCH. Changing email creates pending verification; sending the current email cancels the pending change, while omitting email preserves it.
Validation: POST /api/v1/portal/me/email/verify
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
otp |
JSON body | Required | String of exactly six ASCII digits, pattern [0-9]{6}; preserve leading zeros (for example "012345"). Numeric JSON values are invalid. |
Unlisted payload/query fields are rejected with 422 VALIDATION_ERROR. A conflicting merchant selector may instead be rejected earlier with 403 MERCHANT_SCOPE_MISMATCH. Must match the active pending proof, within 15 minutes and before the incorrect-attempt limit. Missing/expired/incorrect proof returns 422; after five incorrect attempts subsequent verification returns 429 until resend. Email availability is rechecked.
Validation: POST /api/v1/portal/me/email/resend
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
| (none) | Body/query | No documented inputs | No payload is required; see behavior below. |
Send no request body. Any supplied body/query fields are rejected with 422. Requires a pending email change and an available replacement email; otherwise 422. Portal URL configuration must be usable (503 if unavailable).
Validation: PUT /api/v1/portal/me/password
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
current_password |
JSON body | Required | Nonempty string, maximum 128 characters; must match the authenticated user’s current password. |
password |
JSON body | Required | String, 12–128 characters; at least one letter, uppercase and lowercase letters, number and symbol. Must exactly match password_confirmation. |
password_confirmation |
JSON body | Required by confirmation | Same value as password; do not trim passwords. |
The current validator does not reject unlisted fields; they are not validated or used by this operation. Send only documented fields. User identity comes from the PAT; a conflicting merchant selector is rejected by merchant context. Successful change revokes user tokens.
Validation: GET /portal/profile/email/verify/{verification}
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
verification |
Path | Required | Verification-record ID supplied in the signed email link; do not construct it. |
proof |
Query | Required for successful verification | Server-generated proof bound to the current pending record; must match exactly. |
expires |
Query | Required by expiring signature | Server-issued signed expiry; must not have expired. |
signature |
Query | Required | Valid Laravel signature for the exact URL. Changing signed parameters or adding query fields invalidates it. |
Send no request body. This is a DLT web URL outside /api/v1. Use the complete emailed link. Invalid signature/expiry returns 403; stale/consumed/ineligible/conflicting proof redirects with email_verification=invalid, as described in 5.2.
5.4 Complete profile request and response examples
The following are synthetic request/response pairs. Replace IDs and credential placeholders with values from your environment. GET requests have no body; filters belong in the query string. Examples show successful operations; validation and access rules above still apply.
GET /portal/me
Request (no body):
GET /api/v1/portal/me
Accept: application/json
Authorization: Bearer <user_pat>
Response: 200.
{
"success": true,
"message": "Success",
"data": {
"id": 42,
"reference_id": "USR_EXAMPLE",
"first_name": "Alex",
"last_name": "Reyes",
"username": "alex.reyes",
"email": "merchant.admin@example.com",
"pending_email": null,
"pending_email_expires_at": null,
"email_verified_at": "2026-09-01T00:00:00Z",
"role": "administrator",
"is_active": true,
"merchant": {
"id": 7,
"reference_id": "MER_EXAMPLE",
"name": "Example Merchant"
}
}
}
Update names and request an email change together. The current login email remains in email until verification succeeds. For a names-only edit, omit email; pending verification is preserved.
PATCH /portal/me
Request:
PATCH /api/v1/portal/me
Accept: application/json
Authorization: Bearer <user_pat>
Content-Type: application/json
{
"first_name": "Alexandra",
"last_name": "Reyes",
"email": "replacement@example.com"
}
Response: 200.
{
"success": true,
"message": "Profile updated.",
"data": {
"id": 42,
"reference_id": "USR_EXAMPLE",
"first_name": "Alexandra",
"last_name": "Reyes",
"username": "alex.reyes",
"email": "merchant.admin@example.com",
"pending_email": "replacement@example.com",
"pending_email_expires_at": "2026-10-03T09:15:00+08:00",
"email_verified_at": "2026-09-01T00:00:00Z",
"role": "administrator",
"is_active": true,
"merchant": {
"id": 7,
"reference_id": "MER_EXAMPLE",
"name": "Example Merchant"
}
}
}
POST /portal/me/email/verify
Request:
POST /api/v1/portal/me/email/verify
Accept: application/json
Authorization: Bearer <user_pat>
Content-Type: application/json
{
"otp": "123456"
}
Response: 200.
{
"success": true,
"message": "Your email address has been verified.",
"data": {
"id": 42,
"reference_id": "USR_EXAMPLE",
"first_name": "Alexandra",
"last_name": "Reyes",
"username": "alex.reyes",
"email": "replacement@example.com",
"pending_email": null,
"pending_email_expires_at": null,
"email_verified_at": "2026-10-03T09:02:00+08:00",
"role": "administrator",
"is_active": true,
"merchant": {
"id": 7,
"reference_id": "MER_EXAMPLE",
"name": "Example Merchant"
}
}
}
Resend applies while verification is pending (before the successful verification example above).
POST /portal/me/email/resend
Request (no body):
POST /api/v1/portal/me/email/resend
Accept: application/json
Authorization: Bearer <user_pat>
Response: 200.
{
"success": true,
"message": "A new verification link and OTP have been sent.",
"data": null
}
PUT /portal/me/password
Request:
PUT /api/v1/portal/me/password
Accept: application/json
Authorization: Bearer <user_pat>
Content-Type: application/json
{
"current_password": "<current_password>",
"password": "<new_password>",
"password_confirmation": "<new_password>"
}
Response: 200.
{
"success": true,
"message": "Password updated. Please log in again.",
"data": {
"reauthentication_required": true
}
}