7. Team management
These APIs manage the registered business's users inside DLT Merchant Portal. Both roles may list members. Administrator manages members/invitations. Acceptance uses the confidential Portal application and emailed proof, without a PAT.
7.1 Endpoints
| Method and path | Access | Body/query | Result | Status |
|---|---|---|---|---|
GET /portal/team/members |
Both | page, per_page |
200, member list |
Implemented |
GET /portal/team/invitations |
Administrator | Pagination; optional status |
200, invitation list |
Implemented |
POST /portal/team/invitations |
Administrator | email, role |
201, invitation metadata |
Implemented |
POST /portal/team/invitations/{invitation_id}/resend |
Administrator | None | 200, new expiry and sent status |
Implemented |
DELETE /portal/team/invitations/{invitation_id} |
Administrator | None | 204, revoked invitation |
Implemented |
POST /portal/team/invitations/accept |
Confidential Portal application plus invitation proof | Acceptance fields below | 200, accepted membership; then normal login |
Implemented |
PATCH /portal/team/members/{user_id}/role |
Administrator | role |
200, updated member |
Implemented |
DELETE /portal/team/members/{user_id} |
Administrator | None | 204, membership removed |
Implemented |
Member data: id, first_name, last_name, email, role, is_active, and joined_at. Invitation data: id, email, role, status, invited_by, created_at, and expires_at. Invitation statuses are pending, accepted, expired, and revoked. Never include invitation tokens in list/create responses.
Invitation creation:
{
"email": "developer@example.com",
"role": "developer"
}
201 response:
{
"success": true,
"message": "Invitation sent.",
"data": {
"id": "9b94f9dc-5482-45a5-bc65-8b360d858fc7",
"email": "developer@example.com",
"role": "developer",
"status": "pending",
"invited_by": 42,
"created_at": "2026-10-01T09:00:00Z",
"expires_at": "2026-10-03T09:00:00Z",
"sent": true
}
}
7.2 Invitations and acceptance
Invitations are valid for 48 hours and bind a normalized lowercase email to the merchant, current inviter, and exact administrator/developer role. Creation requires a valid email up to 255 characters and rejects extra fields. Resend and revoke accept no fields. UUID route syntax and merchant ownership are enforced; unknown/foreign IDs return 404. Only Administrators may list or manage invitations; Developer denial precedes validation.
GET uses the standard 25-item pagination default, per_page 1–100, and newest creation timestamp/UUID first. Optional status accepts pending, accepted, expired, or revoked; expired means an unconsumed pending record whose expiry has passed. Lists expose only the seven invitation fields in section 7.1. Creation/resend adds sent; no response includes the token, hash, or acceptance URL.
Each proof is 64 lowercase hexadecimal characters generated from 32 random bytes. Only its SHA-256 hash is persisted. Email links use DLT's Merchant Portal URL (APP_MERCHANT_URL) plus /team/invitations/accept#token=<proof>. The Portal must implement that page, extract the fragment, and have its backend submit the proof to the API; the fragment stays out of server URL query logs. No GET request consumes an invitation.
Email is sent synchronously after the audited transaction commits. sent: true means the mail send completed, not guaranteed inbox delivery. If transport throws, the invitation remains pending and the response includes sent: false with message Invitation saved. Email delivery failed; resend to retry.; provider diagnostics and secret-bearing content are not returned/logged. Resend generates a new proof, resets password-attempt count, and restarts 48-hour expiry. Old proof returns 404. An expired pending invitation may be resent; accepted/revoked invitations return 410 INVITATION_UNAVAILABLE. Another pending invitation for the same merchant/email blocks resend with 409 INVITATION_ALREADY_PENDING. Revoke returns 204, including repeated revocation, and preserves history; accepted invitations cannot be revoked (410).
Acceptance is a dedicated pre-membership flow. The Portal backend authenticates with the same Basic client credentials as login and X-DLT-Application: merchant_portal; optional Origin must match the configured allowlist. Invalid credentials, another application, or a disallowed origin return 401 ECOSYSTEM_CLIENT_UNAUTHENTICATED before accepting the body. Use the Portal application credentials for this pre-login request; no user PAT is required. Submit token plus:
-
New account: required
first_nameandlast_name, each 1–50 characters, pluspasswordandpassword_confirmation. Password is 12–128 characters with letters, mixed case, numbers, and symbols, and matching confirmation. The invitation email becomes the initial username/email. -
Existing detached account: only
tokenandcurrent_password(required, max 128 characters). Additional name/new-password fields are rejected. Only a detached Administrator/Developer account may join; membership in either this or another merchant is a conflict, and internal DLT accounts are ineligible. An eligible detached inactive account is reactivated after its existing password is proven; password/profile/username remain intact.
Example new-account acceptance body:
{
"token": "<proof_from_invitation_email>",
"first_name": "Example",
"last_name": "Member",
"password": "<new_password>",
"password_confirmation": "<new_password>"
}
Acceptance requires the inviter to remain an active Administrator of the active merchant. Merchant and role come from the invitation. Success returns the member projection, message Invitation accepted. Log in to continue., and no PAT. The invitation email is verified; prior user tokens are revoked.
Additional rules:
- Reject invalid roles and malformed email with
422. - Return
409 MEMBER_ALREADY_EXISTSfor an existing member;409 INVITATION_ALREADY_PENDINGfor a duplicate pending invitation. - Under the initial single-merchant model, return
409 USER_BELONGS_TO_ANOTHER_MERCHANTrather than silently moving an existing user. - Malformed proofs/body/passwords return
422 VALIDATION_ERROR; unknown proofs (including rotated proofs) return404; expired, revoked, consumed, or otherwise unavailable invitations return410 INVITATION_UNAVAILABLE. - Internal DLT accounts return
409 USER_NOT_ELIGIBLE; reserved/colliding username/email identities or a concurrent unique-account conflict return409 USER_IDENTITY_CONFLICT. - Wrong current password returns
422oncurrent_password, commits a failed-confirmation audit and attempt count, and leaves membership unchanged. After 10 wrong-password attempts, acceptance returns410; an Administrator must resend to reset the count and rotate the proof.
Create/resend/revoke share 5 requests/minute per Administrator/merchant. Acceptance limits both 5/minute per proof-hash/IP and 5/minute per IP, plus the API limit. 429 RATE_LIMITED includes retry headers. All invitation responses are no-store. Tokens are excluded from validation-session flashing and response serialization.
7.3 Member listing and mutations
GET returns only this merchant's Administrator/Developer accounts, including inactive members, ordered by ascending user ID. Detached users and internal DLT roles are excluded. Pagination defaults to 25; per_page accepts 1–100 and page starts at 1. Empty/out-of-range pages use an empty data array with normal meta. Invalid pagination returns 422 VALIDATION_ERROR. Each item contains only the seven fields listed in section 7.1. The legacy schema has no separate membership-start timestamp, so joined_at currently projects the account's created_at (nullable), not a reconstructed invitation acceptance date.
PATCH accepts only this body, with exact lowercase role strings:
{
"role": "developer"
}
Successful PATCH returns 200, message Member role updated., and the updated member:
{
"success": true,
"message": "Member role updated.",
"data": {
"id": 42,
"first_name": "Example",
"last_name": "Member",
"email": "developer@example.com",
"role": "developer",
"is_active": true,
"joined_at": "2026-10-01T09:00:00+08:00"
}
}
DELETE accepts no input fields and returns 204 with no body. Both mutations require an active Merchant Administrator; Developer denial precedes field validation. Numeric roles, unsupported roles, or unexpected fields return 422 VALIDATION_ERROR. Member IDs must be positive canonical decimal integers, at most 20 digits; malformed IDs return 404 NOT_FOUND. A missing, detached, other-merchant, or internal-role target also returns 404, without exposing its account data. Conflicting merchant selectors return 403 MERCHANT_SCOPE_MISMATCH.
Last active Administrator: demotion/removal returns 409 LAST_ADMINISTRATOR if the target is the sole active Administrator. Inactive Administrators do not satisfy the invariant. An Administrator may demote/remove themselves when another active Administrator remains. Changing an inactive member's role does not activate them. Repeating the current role returns 200 without changing membership, revoking tokens, or creating another audit. Repeating removal returns 404 because the user is no longer a member.
Access revocation and removal: actual role changes and removals revoke the target user's active Passport access tokens and all associated refresh tokens (including those tied to already-revoked access tokens) and delete their Sanctum tokens using the existing user-token service. Other users and machine-client credentials remain unaffected. Role changes retain the membership; the target logs in again with the new role. Removal sets merchant_id to null, is_active to false, and clears remember_token; it preserves the user/password, merchant transactions, and audit records. Removal grants no access to another merchant.
Successful self-demotion/removal revokes the current PAT. Clear the Portal session after success; an active demoted member can log in again as Developer.
Team mutations are limited to 5/minute per actor, merchant, and operation, alongside the API limit. 429 RATE_LIMITED includes retry headers. GET/PATCH/DELETE responses use Cache-Control: no-store, private.
7.4 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/team/members
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
page |
Query | Optional; default 1 | Integer, minimum 1. Null/empty values are invalid. |
per_page |
Query | Optional; default 25 | Integer, 1–100 inclusive. Null/empty values are invalid. |
Send no request body. Pagination is validated; the list validator does not reject extra fields, which do not add filters. Merchant context still applies.
Validation: PATCH /api/v1/portal/team/members/{member}/role
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
member |
Path | Required | Positive canonical decimal ID, 1–20 digits; pattern [1-9][0-9]{0,19}. No leading zero, sign or decimal point. Malformed, missing or inaccessible resources return 404 NOT_FOUND. User ID from the member directory. |
role |
JSON body | Required | String; exactly administrator or developer. Numeric roles, alternate case, null and 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. Administrator only. Demoting the last active Administrator returns 409 LAST_ADMINISTRATOR; actual role changes revoke the target’s user tokens.
Validation: DELETE /api/v1/portal/team/members/{member}
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
member |
Path | Required | Positive canonical decimal ID, 1–20 digits; pattern [1-9][0-9]{0,19}. No leading zero, sign or decimal point. Malformed, missing or inaccessible resources return 404 NOT_FOUND. |
Send no request body. Any supplied body/query fields are rejected with 422. Administrator only; removing the last active Administrator returns 409 LAST_ADMINISTRATOR.
Validation: GET /api/v1/portal/team/invitations
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
page |
Query | Optional; default 1 | Integer, minimum 1. Null/empty values are invalid. |
per_page |
Query | Optional; default 25 | Integer, 1–100 inclusive. Null/empty values are invalid. |
status |
Query | Optional; required if supplied | Exactly pending, accepted, expired or revoked; null/empty is invalid. |
Send no request body. Administrator only. The list validator does not reject extra fields; they do not add filters.
Validation: POST /api/v1/portal/team/invitations
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
email |
JSON body | Required | RFC-valid email string, maximum 255 characters; normalized lowercase; cannot conflict with existing membership or another pending invitation for that merchant/email. |
role |
JSON body | Required | String; exactly administrator or developer. |
Unlisted payload/query fields are rejected with 422 VALIDATION_ERROR. A conflicting merchant selector may instead be rejected earlier with 403 MERCHANT_SCOPE_MISMATCH. Administrator only. Membership/identity conflicts return the documented 409 codes; no invitation proof is returned in the response.
Validation: POST /api/v1/portal/team/invitations/{invitation}/resend
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
invitation |
Path | Required | UUID route identifier from the returned resource. Invalid syntax, missing or foreign resources return 404 NOT_FOUND. |
Send no request body. Any supplied body/query fields are rejected with 422. Administrator only. Pending/expired-pending invitations may be resent; accepted/revoked invitations return 410; another pending invitation for the same merchant/email returns 409.
Validation: DELETE /api/v1/portal/team/invitations/{invitation}
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
invitation |
Path | Required | UUID route identifier from the returned resource. Invalid syntax, missing or foreign resources return 404 NOT_FOUND. |
Send no request body. Any supplied body/query fields are rejected with 422. Administrator only. Accepted invitation returns 410; repeated revocation returns 204.
Validation: POST /api/v1/portal/team/invitations/accept
| Field | Location | Requirement | Validation rules |
|---|---|---|---|
token |
JSON body | Required in both flows | String of exactly 64 lowercase hexadecimal characters; pattern [a-f0-9]{64}. Must match the latest unconsumed emailed proof, valid for 48 hours. |
first_name |
JSON body | Required for new account only | Nonempty string, maximum 50 characters. Prohibited for an existing detached account. |
last_name |
JSON body | Required for new account only | Nonempty string, maximum 50 characters. Prohibited for an existing detached account. |
password |
JSON body | Required for new account only | String, 12–128 characters; letters, uppercase/lowercase, number, symbol; must match confirmation. Prohibited for an existing detached account. |
password_confirmation |
JSON body | Required for new account only | Nonempty string, maximum 128 characters; exact match to password. Prohibited for an existing detached account. |
current_password |
JSON body | Required for existing detached account only | Nonempty string, maximum 128 characters; must match that existing account. Prohibited for a new account. |
Unlisted payload/query fields are rejected with 422 VALIDATION_ERROR. A conflicting merchant selector may instead be rejected earlier with 403 MERCHANT_SCOPE_MISMATCH. The server selects the flow from the invitation email’s existing account, not a caller flag. Do not combine both bodies or send email, role or merchant/user selectors. Basic Portal application authentication is required. Inviter and merchant must remain active/eligible. Valid-shape unknown token returns 404; expired/revoked/consumed proof returns 410. Existing membership/identity conflicts return 409.
7.5 Complete team 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/team/members
Request (no body):
GET /api/v1/portal/team/members?page=1&per_page=25
Accept: application/json
Authorization: Bearer <user_pat>
Response: 200.
{
"success": true,
"message": "Success",
"data": [
{
"id": 43,
"first_name": "Example",
"last_name": "Member",
"email": "developer@example.com",
"role": "developer",
"is_active": true,
"joined_at": "2026-10-03T09:00:00+08:00"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
}
}
PATCH /portal/team/members/43/role
Request:
PATCH /api/v1/portal/team/members/43/role
Accept: application/json
Authorization: Bearer <user_pat>
Content-Type: application/json
{
"role": "administrator"
}
Response: 200.
{
"success": true,
"message": "Member role updated.",
"data": {
"id": 43,
"first_name": "Example",
"last_name": "Member",
"email": "developer@example.com",
"role": "administrator",
"is_active": true,
"joined_at": "2026-10-03T09:00:00+08:00"
}
}
DELETE /portal/team/members/43
Request (no body):
DELETE /api/v1/portal/team/members/43
Accept: application/json
Authorization: Bearer <user_pat>
Response: 204 No Content — empty body; do not parse JSON.
HTTP/1.1 204 No Content
GET /portal/team/invitations
Request (no body):
GET /api/v1/portal/team/invitations?page=1&per_page=25&status=pending
Accept: application/json
Authorization: Bearer <user_pat>
Response: 200.
{
"success": true,
"message": "Success",
"data": [
{
"id": "9b94f9dc-5482-45a5-bc65-8b360d858fc7",
"email": "developer@example.com",
"role": "developer",
"status": "pending",
"invited_by": 42,
"created_at": "2026-10-01T09:00:00Z",
"expires_at": "2026-10-03T09:00:00Z"
}
],
"meta": {
"current_page": 1,
"per_page": 25,
"total": 1,
"last_page": 1
}
}
POST /portal/team/invitations
Request:
POST /api/v1/portal/team/invitations
Accept: application/json
Authorization: Bearer <user_pat>
Content-Type: application/json
{
"email": "developer@example.com",
"role": "developer"
}
Response: 201.
{
"success": true,
"message": "Invitation sent.",
"data": {
"id": "9b94f9dc-5482-45a5-bc65-8b360d858fc7",
"email": "developer@example.com",
"role": "developer",
"status": "pending",
"invited_by": 42,
"created_at": "2026-10-01T09:00:00Z",
"expires_at": "2026-10-03T09:00:00Z",
"sent": true
}
}
POST /portal/team/invitations/9b94f9dc-5482-45a5-bc65-8b360d858fc7/resend
Request (no body):
POST /api/v1/portal/team/invitations/9b94f9dc-5482-45a5-bc65-8b360d858fc7/resend
Accept: application/json
Authorization: Bearer <user_pat>
Response: 200.
{
"success": true,
"message": "Invitation sent.",
"data": {
"id": "9b94f9dc-5482-45a5-bc65-8b360d858fc7",
"email": "developer@example.com",
"role": "developer",
"status": "pending",
"invited_by": 42,
"created_at": "2026-10-01T09:00:00Z",
"expires_at": "2026-10-05T09:00:00+08:00",
"sent": true
}
}
DELETE /portal/team/invitations/9b94f9dc-5482-45a5-bc65-8b360d858fc7
Request (no body):
DELETE /api/v1/portal/team/invitations/9b94f9dc-5482-45a5-bc65-8b360d858fc7
Accept: application/json
Authorization: Bearer <user_pat>
Response: 204 No Content — empty body; do not parse JSON.
HTTP/1.1 204 No Content
Acceptance examples are alternatives using a valid, unrevoked invitation; do not use the invitation deleted above. Replace the token with the 64-character proof from the latest email.
POST /portal/team/invitations/accept
Request:
POST /api/v1/portal/team/invitations/accept
Accept: application/json
Authorization: Basic <base64(ecosystem_client_id:ecosystem_client_secret)>
X-DLT-Application: merchant_portal
Content-Type: application/json
{
"token": "<proof_from_invitation_email>",
"first_name": "Example",
"last_name": "Member",
"password": "<new_password>",
"password_confirmation": "<new_password>"
}
Response: 200.
{
"success": true,
"message": "Invitation accepted. Log in to continue.",
"data": {
"id": 43,
"first_name": "Example",
"last_name": "Member",
"email": "developer@example.com",
"role": "developer",
"is_active": true,
"joined_at": "2026-10-03T09:00:00+08:00"
}
}
For an eligible existing detached account, use this body instead. The response has the same member envelope, retaining that account's existing profile and account creation timestamp.
POST /portal/team/invitations/accept
Request:
POST /api/v1/portal/team/invitations/accept
Accept: application/json
Authorization: Basic <base64(ecosystem_client_id:ecosystem_client_secret)>
X-DLT-Application: merchant_portal
Content-Type: application/json
{
"token": "<proof_from_invitation_email>",
"current_password": "<existing_account_password>"
}
Response: 200.
{
"success": true,
"message": "Invitation accepted. Log in to continue.",
"data": {
"id": 43,
"first_name": "Example",
"last_name": "Member",
"email": "developer@example.com",
"role": "developer",
"is_active": true,
"joined_at": "2026-09-01T00:00:00+08:00"
}
}