# Mobile integration guide: mandate status, Paystack retry and Mono retry

For the explicit initial setup, retry and broken-link endpoints, see [Mono three-action frontend guide](mono-three-actions-fe-guide.md).

Verified against the backend source in this checkout on 9 October 2026. Examples use illustrative references and URLs; confirm the deployed API runs the same revision.

All endpoints need the borrower bearer token: `Authorization: Bearer <token>`. Base path `/api/v1`.

For Direct Debit readiness, the backend uses the latest mandate per provider. An older active mandate must not be used by the mobile app to override a newer failed mandate. Always use the returned readiness fields; Paystack verification also supports legacy card authorization and employer exemptions. Consequently, `paystack.linked` can be true even when its Direct Debit `status` is not `active`.

## 1. Check status (one call for both)

`GET /api/v1/profile/mandates/status`

This is a **live** check: the API asks Mono and Paystack for the latest mandate's real state,
corrects our records if they disagree (and emails the borrower + ops when a mandate failed),
then responds. If a provider is unreachable, the last saved state is returned and
`live_check.checked` is `false`. Expect a few seconds of latency — don't poll faster than
every ~10 seconds.

```json
{
  "mono": {
    "required": true,
    "linked": false,
    "status": "rejected",
    "mandate_id": "mmc_6820...",
    "authorization_url": null,
    "relink_available": true,
    "relink": { "method": "POST", "path": "/api/v1/loans/drafts/LND-ABC123/collection", "body": {} },
    "live_check": { "checked": true, "action": "marked_rejected" }
  },
  "paystack": {
    "required": true,
    "linked": true,
    "status": "active",
    "awaiting_bank_activation": false,
    "authorization_url": null,
    "relink_available": false,
    "relink_available_at": "2026-10-02T15:00:00+01:00",
    "relink": { "method": "POST", "path": "/api/v1/profile/paystack-mandate/retry", "body": {} },
    "live_check": { "checked": true, "action": "no_change" }
  },
  "fully_verified": false
}
```

| Field | Meaning |
| :--- | :--- |
| `linked` | **Use this.** `true` = backend considers this provider requirement satisfied (including Paystack exemptions/legacy authorization). |
| `required` | `false` = the borrower's employer doesn't use this rail; treat it as done. |
| `status` | Latest mandate state: `not_started`, `pending`, `approved`, `active`, `rejected`, `cancelled`, `expired`, `failed`… |
| `authorization_url` | A link the borrower still has to complete. Open it if present. |
| `relink_available` | Show a "Set up again" button when `true`. |
| `relink` | Exactly which endpoint to call to relink (see section 2). |
| `awaiting_bank_activation` | Paystack only: borrower already authorized; waiting on the bank. Don't relink. |
| `live_check.checked` | `false` = no mandate to check or lookup failed; inspect `action`. On lookup failure, values use saved state. |
| `live_check.action` | What changed now, e.g. `marked_rejected`, `marked_active`, `marked_failed`, `no_change`, `no_mandate`, `lookup_failed`. |
| `fully_verified` | All onboarding steps + both required rails are done. |

Use `required` and `linked` for the profile setup UI. For salary draft submission, use `application.can_apply` from the draft endpoint: profile readiness alone does not prove the draft has the correct Mono mandate attached. `fully_verified` includes other onboarding steps and is not a substitute for `can_apply`.

## 2. Relink

Call whatever `relink` says in the status response. The options are:

### Paystack

`POST /api/v1/profile/paystack-mandate/retry` — no body.

Open `mandate.authorization_url` from the response. Rules:

- Latest mandate active → returns it, no new link.
- Borrower already authorized, waiting on bank (`awaiting_bank_activation: true`) → no new link.
- No previous mandate → creates an initial link.
- Latest mandate **cancelled / expired** → eligible for replacement immediately.
- Latest mandate **failed** with a provider failure recorded by the live recheck → eligible immediately. Other failures require no recorded consent and a mandate at least 3 hours old.
- Latest mandate **pending**, with no recorded consent → same link before 3 hours; replacement eligible after 3 hours.
- Use `relink_available` from the status endpoint rather than calculating eligibility from status or timestamps yourself.

### Mono — with a loan draft

`POST /api/v1/loans/drafts/{reference}/collection` with `{}` for normal setup or resume.

Checks Mono live and attaches the reused or replacement mandate to that loan application. Open `application.collection.authorization_url`
and poll `GET /api/v1/loans/drafts/{reference}` until `application.collection.status` is `active`.
Always use this one when a draft exists — otherwise the draft keeps pointing at the old mandate
and the loan can't be submitted.

Get the reference from `GET /api/v1/loans/drafts/current` → `application.reference`
(`application: null` means no draft).

### Mono — no loan draft

`POST /api/v1/bank-statements/retry-mandate` — no body.

Checks or creates a mandate not tied to any loan, using the borrower's linked bank account. A previous analysis is optional; user and linked bank details are required (422 when missing). Open
`mandate_url` from the response. A draft created later picks it up automatically once active.

### Mono click and recovery contract

Every Mono setup POST checks the newest mandate across both local tables under a per-user database lock. All borrower/admin creation paths use the same durable reservation. Normal repeats reuse a valid pending mandate or continue with an active mandate for the same account.

- `action: authorize`: open the returned URL; `reused` tells you whether creation occurred.
- `action: active`: continue; no URL or new mandate.
- `action: awaiting_activation`: show bank processing; no URL or replacement.
- `action: retry_later`: show a wait state until `retry_available_at`; no URL or replacement.
- After a link error, send `{"force_new": true, "failed_mandate_id": "<the ID whose link failed>"}` to the same setup endpoint. Active/approved mandates are still reused. Recovery is delayed until five minutes after original creation, and a request for an older ID cannot replace a newer fresh pending mandate.
- A pending link older than one hour, a missing link, or eligible recovery requires successful provider cancellation **and** a live terminal status before replacement. If Mono does not allow cancellation, show the server message and wait for provider expiry or support. Local expiry alone never proves cancellation.
- Lookup errors return 503; rejected/unconfirmed cancellation returns 409. Neither creates another mandate.
- A creation timeout returns 503 and keeps the request reference. For ten minutes after that, calls return 409 with `mono.code: mono_setup_unconfirmed` and status reports `setup_unconfirmed` (link/relink hidden), so a setup still posting cannot be duplicated. After that, retry first tries to recover the mandate by its exact reference; if it cannot, it replaces the unconfirmed setup with a new mandate and writes the old reference to the borrower Activity log (owner decision, 2026-10-26). Loans debit only the mandate they store, so an orphaned Mono mandate is never debited by us.

Disable buttons during requests. After returning from Mono, run the live profile status check and then refresh the draft; use `can_apply` to enable submission.

Deploy the API migration `2026_10_09_100000_create_mono_mandate_requests_table.php` before serving the changed API, then deploy the affiliate/admin UI together. The native mobile source is outside this workspace; its buttons must follow this contract.

Support can inspect an unknown request with `php artisan mandates:reconcile-mono-setup USER_ID`, then locate its exact reference in Mono. Record a confirmed existing mandate with `php artisan mandates:reconcile-mono-setup USER_ID MONO_MANDATE_ID`. The command checks the provider reference and never creates a mandate. If Mono confirms no mandate was created, use `--confirmed-not-created` and answer the confirmation prompt. Do not clear a reservation from a timeout alone. In-flight requests have a ten-minute guard before manual recovery.

Provider lifecycle/cancellation references: [Mono overview](https://docs.mono.co/docs/payments/direct-debit/overview), [cancel mandate](https://docs.mono.co/api/direct-debit/mandate/cancel-mandate). The one-hour link window is conservative: recovery still requires provider confirmation, including for existing mandates created under the old 24-hour local expiry.

## 3. Bank statement upload: new rejection

`POST /api/v1/bank-statements/analyze-upload` (and `POST /api/v1/loans/retail/analyze`) now reject
documents that aren't bank statements (CVs, IDs, random PDFs), or where no transactions were found:

```json
// 422
{ "message": "This document does not look like a bank statement. ...", "code": "not_a_bank_statement" }
```

Show `message` and let the borrower pick another file. Nothing is saved as analyzed and no
mandate is started.

## Flow summary

1. `GET /profile/mandates/status`.
2. For each rail with `required: true` and `linked: false`:
   - Paystack `awaiting_bank_activation: true` → show “Awaiting bank activation”; do not start another authorization.
   - Failed/rejected/cancelled/expired/declined/revoked, or an explicit user choice to set up again, with `relink_available: true` → call `relink`.
   - Mono “Continue authorization” → call the setup POST again and open only the URL it returns, so the stored link is checked first.
   - Paystack usable `authorization_url` → offer “Continue authorization”.
   - No usable URL and `relink_available: true` → offer “Set up again”.
   - No URL and no retry eligibility → show pending state and allow status refresh.
   - Do not open a failed mandate’s old URL merely because a URL is present; Paystack can retain it in its payload.
3. After the borrower returns from the link, call `GET /profile/mandates/status` again.

## 4. Endpoint and response reference

Send these headers on all requests:

```http
Authorization: Bearer <borrower_token>
Accept: application/json
Content-Type: application/json
```

`Content-Type` is relevant when sending JSON. All paths below already include `/api/v1`; avoid adding that prefix twice in the API client.

| Purpose | Method and path | Request body | Response to read |
| --- | --- | --- | --- |
| Live status for both providers | `GET /api/v1/profile/mandates/status` | None | `mono`, `paystack`, `fully_verified` at root |
| Find current salary draft | `GET /api/v1/loans/drafts/current` | None | `application.reference`, or `application: null` |
| Paystack retry | `POST /api/v1/profile/paystack-mandate/retry` | None (or `{}`) | `mandate.authorization_url`, `mandate.status`, `mandate.awaiting_bank_activation`, `mandate.refreshed` |
| Mono broken-link recovery for existing draft | `POST /api/v1/loans/drafts/{reference}/collection` | `{"force_new": true, "failed_mandate_id": "mmc_existing"}` | `application.collection.authorization_url`, `status`, `action`, `retry_available_at` |
| Mono initial setup/resume for draft | Same collection endpoint | `{}` or `{"force_new": false}` | Same application fields |
| Mono retry without draft | `POST /api/v1/bank-statements/retry-mandate` | None (or `{}`) | Root `mandate_url`, `mandate_id` |
| Check draft readiness | `GET /api/v1/loans/drafts/{reference}` | None | `application.collection`, `application.paystack`, `application.can_apply` |

### Paystack retry response (200; illustrative)

```json
{
  "message": "A new mandate authorization link is ready.",
  "mandate": {
    "status": "pending",
    "reference": "example-paystack-reference",
    "authorization_url": "https://provider.example/authorize",
    "awaiting_bank_activation": false,
    "created_at": "2026-10-03T10:00:00+01:00",
    "expires_at": "2026-10-03T13:00:00+01:00",
    "retry_available": false,
    "retry_available_at": "2026-10-03T13:00:00+01:00",
    "attempt": 1,
    "refreshed": true
  }
}
```

`refreshed: false` means the existing mandate was returned. A successful HTTP response is not proof of activation. The retry service passes `retry_available: false` in its response, so refresh the combined status endpoint for current eligibility. Treat `retry_available_at` / `relink_available_at` as informational timestamps: they can be present for active mandates or already be in the past. They do not override the eligibility boolean.

When no Paystack mandate exists, the status service returns a smaller payload (`not_started`, null URL/timestamp, retry available, attempt 0). Do not require all optional mandate fields to exist.

### Draft response (200; relevant fields only)

```json
{
  "application": {
    "reference": "LND-ABC123",
    "status": "collection_pending",
    "collection": {
      "status": "pending",
      "authorization_url": "https://provider.example/mono-authorize",
      "mandate_activation_id": 123
    },
    "paystack": { "status": "pending" },
    "can_apply": false
  }
}
```

The collection POST also includes a root `message`. Draft states returned by `/current` are `draft`, `collection_pending`, or `ready_to_apply`. `ready_to_apply` reflects Mono readiness; Paystack may still block submission. Read `can_apply` before enabling submit.

Without `force_new`, the collection endpoint reuses an attached valid active mandate, returns an attached non-terminal mandate as-is, replaces terminal mandates, or attaches the latest valid active Mono mandate when no mandate is attached. With `force_new: true`, it bypasses reuse and starts a fresh authorization, even if the old mandate is active. Use it for a deliberate replacement, never for routine polling.

The current salary draft presenter treats the processing fee as satisfied (fee gating is disabled in this source revision). Do not independently impose a fee requirement based on older API annotations; use `can_apply`.

### Standalone Mono retry response (200)

```json
{
  "status": "success",
  "message": "Mandate re-initiated successfully.",
  "mandate_url": "https://provider.example/mono-authorize",
  "mandate_id": "example-mono-mandate-id"
}
```

This endpoint starts a new pending mandate on each successful call; it does not have Paystack's reuse/cooldown behavior. It does not update an existing draft's mandate attachment. Never use it as the retry route for an existing salary draft.

## 5. Recommended mobile lifecycle

1. On opening the mandate screen or resuming booking, load combined status and the current draft. Store the reference from the returned `application`, not a hardcoded or old reference.
2. Render each provider separately. If `required: false`, skip its profile setup prompt. If `linked: true`, show it as complete. Use the rules in section 3 for unresolved providers.
3. On a user retry action, disable that provider's button while the POST is in progress. Prefer the combined status response's `relink.method`, `relink.path`, and `relink.body`. If selecting the Mono route manually, use the current draft reference when present; otherwise use standalone retry.
4. Replace locally cached mandate URLs/state with the POST response. Extract the URL from the endpoint-specific field in section 4. If it is null, inspect the state and refresh status instead of attempting to launch it.
5. Open the provider URL in the app's supported browser authorization flow. Closing the browser, a redirect, or returning to the app is only a signal to refresh backend status. It must never set `linked`, `active`, or `can_apply` locally.
6. On return, refresh combined status and, when a draft exists, `GET /loans/drafts/{reference}`. Profile Mono state can differ from the draft's attached mandate. Both views need refreshing.
7. While the screen is visible and activation is pending, use a non-overlapping poll roughly every 10 seconds or slower. Pause polling in the background and stop when resolved or the user leaves. After a reasonable waiting period, show a pending message and manual refresh rather than polling indefinitely. This interval is client guidance, not a server-enforced rate limit.
8. Enable salary draft submission only from the latest `application.can_apply`. The backend remains authoritative and may reject submission if state changes.

For Paystack, live status checks can detect failures but do not promote a locally pending mandate to active. `live_check.action: "provider_active_needs_reconciliation"` means the provider reports activation but the backend still needs its verified webhook/reconciliation path. Keep showing the backend readiness state; if it persists, route the borrower to support. Never turn this action into client-side success.

## 6. Errors and recovery

| Result | Mobile handling |
| --- | --- |
| 401 | Use the app's session renewal/sign-in flow; do not retry authorization without a valid borrower session. |
| 403 | Borrower access is not allowed; show the API message. |
| Draft 404 | Reference was not found for this borrower. Reload `/drafts/current` before another collection request. |
| Draft collection 422 | Read `message` and field errors such as `errors.loan` or `errors.collection`. The draft may be closed or Mono initialization may have failed. Refresh draft/status before offering another attempt. |
| Paystack retry 422 | Show `message`. Missing bank details require bank setup; provider initialization failures require status refresh before another attempt. |
| Standalone Mono retry 422 | Missing user, linked bank details or bank statement analysis. Complete the missing prerequisite; repeating the POST will not fix it. |
| Standalone Mono retry 500 | Show `message` and offer recovery after refreshing status. |
| Combined status 200 with `live_check.checked: false` | Inspect `action`: `no_mandate` is a setup state, `lookup_failed` means live confirmation is unavailable and saved state was used. Do not mark the provider failed solely because lookup failed. |
| Timeout/network error/other 5xx | A POST may already have created a mandate. Re-fetch status and draft before another mutation; do not automatically replay Mono replacement requests. |

For example, a draft validation response can have this shape:

```json
{
  "message": "Could not initialize Mono collection.",
  "errors": { "collection": ["Could not initialize Mono collection."] }
}
```

Render the actual server text rather than matching English messages to decide the next state. Prevent double taps and overlapping retries. The previous draft attachment is retained until setup succeeds. Refetch status after errors; do not restore an old cached URL.

## 7. Mobile acceptance checks

- No mandate: offer setup, handle null optional fields, and use the correct URL field.
- Existing pending Paystack link under 3 hours: retry returns the existing mandate; no assumed replacement.
- Paystack consent received: show awaiting bank activation and do not offer another consent flow.
- Paystack provider-confirmed failure: fresh combined status exposes eligible retry.
- Paystack live provider-active reconciliation: keep backend readiness pending until backend confirmation.
- Mono retry with a current draft: call draft collection with `{}`, then refresh that draft. Use the recovery body only after a link error.
- Mono retry without a draft: require a linked bank and read root `mandate_url`, `action`, `reused`, and `retry_available_at`.
- Authorization browser closed/cancelled: refresh; never mark success from browser return alone.
- Lookup failure: retain saved backend state, show refresh availability, avoid an automatic relink.
- `ready_to_apply` with `can_apply: false`: keep submission disabled.
- Retry timeout or repeated button taps: prevent duplicate POSTs and reconcile through GETs first.

## 8. Backend sources

These are the implementation references used to verify this guide:

- Routes: `routes/api/profile.php`, `routes/api/loans.php`, `routes/api/bank-statements.php`.
- Combined response and relink routing: `app/Domains/Borrower/Profile/Controllers/MandateStatusController.php`.
- Paystack response/errors: `app/Domains/Borrower/Profile/Controllers/PaystackMandateAuthorizationController.php`.
- Paystack eligibility and payload: `app/Services/PaystackMandateAuthorizationService.php`.
- Live provider checks and reconciliation: `app/Services/Payments/MandateStatusRecheckService.php`.
- Mono draft attachment/readiness: `app/Domains/Borrower/Loans/Services/SalaryLoanDraftService.php`.
- Standalone Mono prerequisites/response: `app/Services/CreditScoring/BankStatementAnalysisService.php::retryMandateGeneration`.
- Employer exemptions and verification: `app/Services/UserVerificationService.php`.
