> For the complete documentation index, see [llms.txt](https://docs.moderncollections.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.moderncollections.io/rest-api/portal.md).

# Portal

The debtor portal is a separate product surface where debtors authenticate with a magic-link plus OTP and can:

* View their outstanding placement.
* Initiate a Stripe payment.
* Open a dispute and attach evidence.
* Post a reply message.
* Submit a CPRA Data Subject Access Request (public, unauthenticated).

The portal calls the endpoints documented here using a **debtor JWT** scoped to one placement (claims: `placement_id`, `sub` = debtor\_id, `creditor_id`).

A public hosted URL for the portal is not yet published.

## When integrators care about this

Almost never. Reasons you might:

1. You're white-labeling the debtor portal (a planned capability).
2. You're building a custom debtor-facing surface that authenticates via our JWT flow.

If neither applies, skip this page.

## Authentication flow

```
1. Debtor receives an email or SMS with a magic link.
2. They click. The platform validates the token and issues a JWT.
3. POST /v1/portal/access/verify {request_otp: true} sends an OTP.
4. POST /v1/portal/access/verify {otp: "123456"} marks the session OTP-verified.
5. Each subsequent API call sends the JWT as a Bearer token.
6. State-changing routes require otp_verified=true (enforced via require_otp_verified).
```

OTP TTL is 10 minutes. Max attempts before lock-out is 5. OTP request re-issues are capped at 3 per minute and 5 per rolling 60-minute window.

## Endpoints

| Method | Path                                  | OTP required?   | Purpose                                                                                                                          |
| ------ | ------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/v1/portal/access/verify`            | No              | Drive the OTP gate. Body: `{otp?, request_otp?}`. Returns `{otp_required, otp_sent, placement_id, refreshed_token}`.             |
| `GET`  | `/v1/portal/placement`                | No              | Return the placement (singular) bound to this JWT, with creditor + debtor + outstanding balance.                                 |
| `POST` | `/v1/portal/payment-intents`          | Yes             | Create a Stripe PaymentIntent capped at the outstanding balance. Body: `{"amount_cents": <int>}`. Rate-limited 5/min per debtor. |
| `POST` | `/v1/portal/disputes`                 | Yes             | Open a dispute. Body: `{"reason", "narrative?"}`.                                                                                |
| `POST` | `/v1/portal/evidence/signed-upload`   | Yes             | Get a short-lived signed upload URL for a dispute evidence file.                                                                 |
| `GET`  | `/v1/portal/messages`                 | No              | List recent outbound `EmailSend` rows for the placement (most recent 100).                                                       |
| `POST` | `/v1/portal/messages`                 | Yes             | Post a debtor reply (attached to the most recent outbound email).                                                                |
| `POST` | `/v1/portal/dsar`                     | **Public**      | Submit a CPRA Data Subject Access Request. Rate-limited 5/hour per IP.                                                           |
| `GET`  | `/v1/portal/dsar/{dsar_id}?token=...` | **Token-gated** | Check DSAR status; token comes from the acknowledgement email.                                                                   |
| `GET`  | `/v1/portal/notice-at-collection`     | **Public**      | Return the canonical CPRA notice text as `{"content_type": "text/markdown", "body": "..."}`.                                     |

The endpoints documented previously (`/v1/portal/placements` plural, `/v1/portal/payments`, `/v1/portal/payment_plans`, `/v1/portal/cease_contact_requests`) do not exist in code today.

## `POST /v1/portal/payment-intents` example

```bash
curl -X POST https://<api-host>/v1/portal/payment-intents \
  -H "Authorization: Bearer $PORTAL_JWT" \
  -H "Content-Type: application/json" \
  -d '{"amount_cents": 750000}'
```

```json
{
  "payment_intent_id": "pi_...",
  "client_secret": "pi_..._secret_...",
  "amount_cents": 750000
}
```

The amount is capped at the outstanding balance (returns `400` if it exceeds, `409 "No outstanding balance"` if the placement is paid).

## See also

* [Authentication](/getting-started/authentication.md) — overview of all auth surfaces.
* [Compliance: data handling and PII](/compliance-for-integrators/data-handling-pii.md).
* [Payments](/rest-api/payments.md) — counterpart for creditor-side payment recording.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.moderncollections.io/rest-api/portal.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
