13. Merchant Portal implementation examples
Examples below run through the DLT Merchant Portal backend within the DLT ecosystem. Keep secrets and proofs out of browser bundles, persistent browser storage and telemetry.
13.1 Password reset and invitation entry pages
Password reset: implement a Portal reset page, for example https://portal.example.com/reset-password, on the same origin as DLT's configured APP_MERCHANT_URL. The backend submits:
POST /api/v1/auth/reset-password/reset-link
Accept: application/json
Content-Type: application/json
{
"email": "member@example.com",
"reset_url": "https://portal.example.com/reset-password"
}
The reset URL is limited to 150 characters and cannot contain credentials/query/fragment. DLT emails it with ?token=...&email=...; the Portal page extracts those values and submits the new password through its backend:
{
"email": "member@example.com",
"token": "<reset_token_from_email>",
"password": "<new_12_to_128_character_password>",
"password_confirmation": "<same_new_password>"
}
Send to POST /api/v1/auth/reset-password/new-password. New passwords need upper/lowercase letters, numbers and symbols. Reset proof expiry is 60 minutes. Success returns the endpoint's message-only success shape, not a PAT; obtain a new PAT through login. Invalid/expired reset proof returns the existing 400 response, not Portal 410. Use generic account-independent confirmation for requesting a link; never interpret the reset-link success as confirmation that an account exists. Keep token/email query values out of analytics/logs and remove them from the visible URL after transferring them to the page's transient form state; do not persist the proof in normal browser storage.
Invitation acceptance: implement {APP_MERCHANT_URL}/team/invitations/accept with fragment extraction for #token=.... Reading the page does not consume the invitation. There is no public invitation-inspection/preflight or standalone member-registration endpoint. Offer the documented new-account or existing-detached-account form and send its proof through the confidential Portal backend to /api/v1/portal/team/invitations/accept with Basic/application headers. Do not combine Bearer and Basic authorization.
New account uses the fields shown in section 7.2. Existing detached account submits only:
{
"token": "<64_lowercase_hex_invitation_proof>",
"current_password": "<existing_account_password>"
}
Do not send role, merchant ID, email override or new-account names/password fields with the existing-account form. Success returns the accepted member and no PAT; go to normal login. A failed/expired/revoked/consumed proof must not be continually resubmitted. Ask the inviting Administrator to resend when a new proof is needed. Invitations created by the Administrator can return sent:false even with 201; show saved-but-not-emailed state and offer resend instead of automatically creating another invitation.
13.2 Transaction list, history and reporting
Use one visible environment selector across related collection screens; pass it explicitly on every call. When switching modes, reset pagination/selection and discard responses belonging to the previous filter set. A test transaction ID requested with live mode returns 404, even if that ID exists in test. Filter changes should return list pagination to page 1. Offset pagination can shift when new data arrives; the server provides stable ordering within a query, not a frozen multi-page snapshot.
On environment/filter changes, reset pagination and discard stale responses. Use internal transaction IDs and render nullable fields/history gracefully.
Example common reporting query:
// Illustrative Portal BACKEND code. userPat comes from its server-side session.
const filters = {
environment: 'test',
currency: 'PHP',
timezone: 'Asia/Manila',
from: '2026-09-01T00:00:00+08:00',
to: '2026-10-01T00:00:00+08:00',
};
const summaryUrl = new URL('/api/v1/portal/collections/metrics', dltOrigin);
summaryUrl.search = new URLSearchParams(filters).toString();
const chartUrl = new URL('/api/v1/portal/collections/charts', dltOrigin);
chartUrl.search = new URLSearchParams({ ...filters, interval: 'day' }).toString();
const response = await fetch(chartUrl, {
headers: { Accept: 'application/json', Authorization: `Bearer ${userPat}` },
});
const payload = await response.json();
if (!response.ok) {
// Map HTTP status/error_code to the form/session policy in section 11.
// Do not log the user PAT or response bodies from credential operations.
return { status: response.status, error: payload };
}
return { status: response.status, data: payload.data };
This example calls DLT once for the chart; summaryUrl illustrates the separate summary route, not an automatic second call. Adapt its return/error handling to the Portal framework. Do not put this code with a real PAT in browser bundles. URLSearchParams encodes + correctly. Use the same environment/currency/optional brand/from/to for matching screens. Summary has no interval; chart requires it. Lists support optional paired dates/currency/status/reference search, while summary/charts require currency and both instants and reject status/search/pagination fields. Do not share an unfiltered query object across all endpoints.
Use chart data.totals alongside its buckets for consistent totals. Separate summary/chart reads may observe different status changes.
13.3 Response, retry and refresh behavior
| Situation | Portal behavior |
|---|---|
200 list with data:[] |
Render empty state with returned pagination; not an exception |
201 invitation with sent:false |
Render saved invitation/email delivery issue and explicit resend |
| 204 mutation | Treat as successful empty response; update local state or refetch |
| 422 input validation | Bind field errors, keep editable input; use string OTP/proofs and documented password policy |
| 401 protected call | Clear the invalid Portal session and require user login again |
| 403 | Handle role/membership/scope/application error; do not fabricate another merchant selector or retry login in a loop |
| 404 detail | Render unavailable/not-found in selected mode; do not infer another merchant's data |
| 409 lifecycle conflict | Refresh relevant state and show reason; last-admin/invitation conflicts require a changed action |
| 410 one-use proof/delivery | Treat as unavailable; arrange new proof/delivery rather than retrying it |
| 429 | Honor Retry-After when present and slow requests; avoid multiple dashboard polls exhausting shared limits |
| 500/503 | Show bounded retry/recovery for reads; paid-null metrics/currency configuration require DLT intervention |
Read retry should be bounded and respect the current session/filter version; it must not trigger financial processing. Mutations have different idempotency: logo removal and invitation revocation are repeat-safe as documented, but issuance, rotation, consumption, invitation creation and successful acceptance must not be blindly replayed after an ambiguous timeout. Read state first where possible. Credential metadata GET can perform expired-delivery cleanup and history GET creates a read audit; do not advertise all GETs as having no application writes. Collection/summary/chart GETs themselves do not process payments or contact providers.
Use server-side/private session caches only where suitable; honor no-store for credential/profile/history resources and clear merchant/user data when ending a session. Refresh affected resources after mutation instead of assuming every response contains the whole updated page. The API does not provide a WebSocket/SSE stream, cursor pagination, multi-merchant switching, exports, or unrestricted payload downloads in this contract; adding such features requires a separate API agreement.