# Customer portal

> A page per brand where your customers sign in with an email link to see their invoices, pay open ones, manage their subscriptions, credits, cards and billing details, and download their data, in their own language.

The **customer portal** is a hosted page for each of your brands at `/portal/{brandSlug}`. Your [customers](https://ownrecord.io/docs/concepts/customers.md) sign in there, without a password, and look after their own billing:

- **Invoices and credit notes.** Every issued invoice, receipt and credit note, each downloadable as a PDF.
- **Paying open invoices.** An open invoice with something due (for example a `send_invoice` invoice, see [collection](https://ownrecord.io/docs/concepts/collection.md)) can be paid by card on your processor. The bank transfer details are shown too.
- **Subscriptions.** Each [subscription](https://ownrecord.io/docs/concepts/subscriptions.md) with its plan, period, discounts and next invoice, and what your portal settings let customers do with it: switch plan after seeing the price, change the quantity, apply a promotion code, choose the card it charges, cancel and keep it after all.
- **Credits.** Each [wallet](https://ownrecord.io/docs/concepts/wallets.md) with its balance and history, topping up with your credit packs, and automatic reloads.
- **Payment methods.** Saved cards with the default marked. Customers add a card on your processor's setup page (a [checkout](https://ownrecord.io/docs/concepts/checkout.md) in `setup` mode), make another card the default, or remove one. See [payment methods](https://ownrecord.io/docs/concepts/payment-methods.md). A subscription that names its own card keeps charging it when the default changes; while one does, the page offers to charge the default card for those subscriptions instead ([`portal.payment_methods.use_for_subscriptions`](https://ownrecord.io/docs/reference/portal-payment-methods-use-for-subscriptions.md)).
- **Credit balance.** Credit from refunds, plan changes and goodwill, read-only. It's used automatically on the next invoices.
- **Billing details.** Name, business name and billing address, and tax IDs, which are checked with their register when added, as in the console.
- **Their data.** Everything the brand holds about them, as one JSON file or one CSV file.

The portal shows your brand's name, logo and color, links your terms, privacy policy and support address, and says who sells: "Sold by", then your entity's legal name, address and VAT ID. Test-mode portals show a test banner.

## Subscriptions in the portal

What customers may do is your brand's choice, in **Brand → Settings → Customer portal** or with `brands.update` (`portalSettings`):

| Setting                | What it allows                                                                                                                                          | New brands   |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `allowPlanChanges`     | Switching plans, along `planChangeMatrix`                                                                                                               | On           |
| `planChangeMatrix`     | For each price, the prices a subscription on it may switch to. **No row, no switch**, even with plan changes on                                         | Empty        |
| `allowQuantityChanges` | Changing the quantity (seats) of licensed items                                                                                                         | Off          |
| `allowCancel`          | Cancelling                                                                                                                                              | On           |
| `cancelBehavior`       | `period_end`: the subscription runs to the end of the paid period, and can be kept until then. `immediate`: it ends at once, unused time isn't credited | `period_end` |
| `collectCancelReason`  | Asking why: a reason from a list and a comment, both optional, kept on the subscription (`cancellation.feedback`, `cancellation.comment`)               | On           |

A matrix row names a price and the prices it may switch to, all recurring licensed prices of the brand in one mode and one currency. A switch back needs its own row:

```http
PATCH /api/v1/brands/brd_01m4…
Authorization: Bearer or_test_…

{
  "portalSettings": {
    "planChangeMatrix": [
      { "fromPriceId": "price_basic_monthly", "toPriceIds": ["price_pro_monthly", "price_pro_yearly"] },
      { "fromPriceId": "price_pro_monthly", "toPriceIds": ["price_basic_monthly"] }
    ]
  }
}
```

The matrix is checked against your prices and replaced whole; `null` or an empty list removes it. The console's editor shows the rows of its mode and keeps the other mode's.

Before a plan switch or a quantity change, the customer sees exactly what it does, from the same code as [`subscriptions.preview_change`](https://ownrecord.io/docs/reference/subscriptions-preview-change.md): an **upgrade** applies at once, with the unused time of the current plan credited and the rest of the period on the new plan charged, invoiced now and charged to the subscription's card (or sent, for a subscription paid by bank transfer); a **downgrade** waits for the end of the period, with nothing to pay now. The customer never picks how changes are billed: your [billing settings](https://ownrecord.io/docs/concepts/subscriptions.md) apply. Promotion codes follow their own rules (active, unexpired, redemptions left, the customer and first-time restrictions, the minimum amount).

While a cancellation is scheduled, nothing else changes until the customer keeps the subscription. Customers can keep only cancellations they scheduled themselves: one your team scheduled stays. Every change is attributed to the customer in the audit log and sends the usual `subscription.*` webhooks; a portal cancellation records `cancellation.by: "customer"`.

## Credits in the portal

Customers see each wallet's balance, what is available, promotional and held for usage in progress, the next credits to expire, and its history: top-ups, credits added, usage, holds and their release, expiries and refunds. Your team's notes on grants and the ledger's internal references aren't shown.

**Topping up** opens your hosted checkout in `topup` mode with one of your active credit packs (a custom amount within the pack's limits), for the signed-in customer; tax is quoted and your [risk rules](https://ownrecord.io/docs/concepts/risk.md) run there as for any top-up, and the checkout comes back to the portal. The wallet is credited when the payment is confirmed.

**Auto-reload** can be turned on or off. The reload amount must be one you sell as credits (a pack of that price, or within a custom-amount pack's range), and the threshold at most the reload amount, so one reload always restores the balance. It charges the customer's chosen card, else their default card; see [auto-reload](https://ownrecord.io/docs/concepts/wallets.md).

## Languages

The portal speaks English, German, French, Croatian, Italian and Spanish. The first that applies wins: the language the customer picked with the links at the bottom of the page (kept for the portal on that browser), the customer's `locale`, their browser's languages, your brand's default language (**Brand → Settings → Languages**, `checkoutSettings.locales`), English. Picking a language on the page changes the page only, not the customer's `locale`. See [languages](https://ownrecord.io/docs/concepts/languages.md).

## Emails customers get

Besides the sign-in link, customers get these emails about their account, in their language, with a link to the portal where it helps:

| Email                | When                                                                                                                         |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Balance low          | `wallet.low_balance`: the balance fell below the wallet's `lowBalanceThreshold` (with auto-reload, it says a reload follows) |
| Subscription renewed | `subscription.renewed` of a subscription that stays active; the invoice or receipt comes in its own email                    |
| Subscription ends    | `subscription.cancel_scheduled`: when it ends, and that it can be kept until then if the customer canceled                   |
| Subscription ended   | `subscription.canceled`, except after unsuccessful dunning, which dunning's last email already says                          |

## Signing in

Customers sign in with a link we email them:

1. On `/portal/{brandSlug}`, the customer enters their email address. We email a sign-in link if the address belongs to a customer of the brand. The page says the same thing either way, so nobody can find out who your customers are.
2. The link works **once**, for **15 minutes**. It opens a page with a **Sign in** button, so an email scanner that follows links can't use it up.
3. Signing in starts a session of **7 days**, kept in a cookie that only the portal's host can read (HttpOnly, Secure, SameSite=Lax). Customers can sign out at any time.

Only a hash of each link's and each session's token is stored. Requests for links are limited per address (5 per 15 minutes) and per IP address (20 per hour), whether or not the address is a customer's.

With a verified [custom domain](https://ownrecord.io/docs/concepts/custom-domains.md) for the portal, the portal and its sign-in links live there, without the brand's slug in the address (`https://account.example.com/invoices`); without one but with a checkout domain, on the checkout domain (`https://pay.example.com/portal/{brandSlug}`). The session cookie belongs to the host the customer signed in on.

A brand that is still **test-only** has a test-mode portal: it signs in your test-mode customers. Once the brand is **live**, its portal signs in live-mode customers. Draft and suspended brands have no portal.

## Sending customers to the portal yourself

To send a customer to the portal from your own app, for example from a "Manage billing" button, generate a one-time link with the API:

```http
POST /api/v1/portal/sessions
Authorization: Bearer or_test_…
Idempotency-Key: 6b0c1f5e-3c55-4a8b-9a3e-0f6e2a7c9d41
Content-Type: application/json

{ "customerId": "cus_01m4…" }
```

The answer's `url` signs that customer in once, within 15 minutes, in the mode of your key. It needs `customers:write`. In the console, the customer's page has a **Generate portal link** button that does the same.

Generating a link is recorded in the audit log with who asked. It doesn't sign you in: whoever opens the link is signed in as the customer, so give it only to them. Operators never get a customer session of their own, and no role or API key can act as a customer.

## What the portal can do, and what it can't

Customers act only on their own records, in their own brand and mode: a portal session never reaches another customer, another brand or the other mode. Every portal action is an [operation](https://ownrecord.io/docs/reference.md) (`portal.*`) with the permission `portal:read` or `portal:write`, which only a signed-in customer holds. The operator equivalents are the usual customer, invoice and payment method operations.

Changes customers make reach you like any other: a new billing address or tax ID sends `customer.updated`, a new or removed card sends `payment_method.*`, and a paid invoice sends `invoice.paid` to your [webhooks](https://ownrecord.io/docs/concepts/webhooks.md).

## In development

`POST /api/dev/portal-login` with `{ "brandSlug": "genno", "email": "b2c.de@ownrecord.test" }` signs a seeded customer in directly, and `/dev` has a **Sign in to the portal as** button for each seeded customer. The sign-in emails land in the dev mailbox (`/dev/mailbox`). Both work only in development and test environments.
