> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.paymentkit.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paymentkit.com/_mcp/server.

# Customer portal

# Overview

The customer portal is a hosted page where your customers self-serve their billing: update payment methods, view and pay invoices, download invoice PDFs, and change or cancel subscriptions — all without an account or password.

Access is controlled by a **portal session token**. You create a session with your secret key (server-side), then redirect the customer to the returned URL. Every customer-facing request is authenticated by the session token embedded in the URL — never by your secret key. This makes the customer-facing endpoints safe to call from the browser.

There are two distinct API surfaces:

| Surface              | Auth                                   | Prefix                                             | Who calls it                               |
| -------------------- | -------------------------------------- | -------------------------------------------------- | ------------------------------------------ |
| **Session creation** | Secret key (`Authorization: Bearer`)   | `/api/{account_id}/billing-portal-sessions`        | Your server                                |
| **Customer-facing**  | Portal session token (in the URL path) | `/api/billing-portal/token/{billing_portal_token}` | The customer's browser / the hosted portal |

The customer-facing endpoints are documented here for completeness and for teams building a fully custom portal front-end. Most integrations only need to **create a session and redirect** — PaymentKit's hosted portal calls these token endpoints for you.

---

# Creating a portal session

Create a session server-side with your secret key. The response contains a `billing_portal_url` you redirect the customer to, and the token used by every customer-facing endpoint.

#### API

```bash
curl -X POST https://app.paymentkit.com/api/{account_id}/billing-portal-sessions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "cus_abc123",
    "expires_in_hours": 24
  }'
```

#### Dashboard

1. Navigate to a customer's detail page
2. Click **Generate Portal Link**
3. Copy and share the link with your customer

**Request body**

| Field              | Type    | Required | Description                                                                  |
| ------------------ | ------- | -------- | ---------------------------------------------------------------------------- |
| `customer_id`      | string  | Yes      | External ID of the customer (e.g. `cus_abc123`).                             |
| `expires_in_hours` | integer | No       | Session lifetime in hours, between `1` and `720` (30 days). Defaults to `1`. |

**Response**

```json
{
  "id": "bps_prod_...",
  "customer_id": "cus_abc123",
  "billing_portal_url": "https://app.paymentkit.com/customer/billing/BPT_SECURE_TOKEN",
  "expires_in_hours": 24,
  "is_expired": false,
  "created_at": "2026-01-15T12:00:00Z"
}
```

The portal token is the final path segment of `billing_portal_url` (a random, URL-safe string — shown as `BPT_SECURE_TOKEN` above). It is the `{billing_portal_token}` used in every customer-facing request below.

Portal links grant access to a customer's billing data. Send them only through secure channels (email, in-app messages) and keep `expires_in_hours` as short as your flow allows.

---

# Customer-facing API

All customer-facing endpoints are prefixed with:

```
https://app.paymentkit.com/api/billing-portal/token/{billing_portal_token}
```

No `Authorization` header is required — the token in the path is the credential. Requests against an expired or invalid token return `401`, except the session-status endpoint, which stays accessible for expired tokens so the front-end can render an "expired" state with the merchant's branding.

## Session

### Get session status

```
GET /session
```

Lightweight check that works **even for expired tokens**. Returns expiry state plus merchant branding so the front-end can decide whether to redirect and how to render the page.

```json
{
  "is_expired": false,
  "account_name": "Acme Inc.",
  "account_logo": "https://...",
  "support_email": "support@acme.com"
}
```

### Get account

```
GET /account
```

Returns the merchant account (branding, name, and public details) for the session.

## Customer

### Get customer

```
GET /customer
```

Returns the customer tied to the session.

### Update customer

```
PUT /customer
```

Update the customer's contact and billing details. All fields are optional; only the fields you send are changed.

| Field              | Type      | Description                 |
| ------------------ | --------- | --------------------------- |
| `email`            | string    | Primary email.              |
| `first_name`       | string    | First name.                 |
| `last_name`        | string    | Last name.                  |
| `phone`            | string    | Phone number.               |
| `billing_email`    | string    | Email invoices are sent to. |
| `business_name`    | string    | Business/legal name.        |
| `language`         | string    | Preferred language code.    |
| `tax_ids`          | string\[] | Tax identifiers.            |
| `address`          | object    | Billing address.            |
| `shipping_address` | object    | Shipping address.           |

## Payment methods

### List payment methods

```
GET /payment-methods
```

Returns the customer's saved payment methods.

### Add a payment method

```
POST /setup-checkout-session
```

Creates a setup checkout session for securely collecting a new payment method. The response returns a `secure_token` and the account's tokenization configuration, which you pass to PaymentKit.js to render the card form.

```json
{
  "secure_token": "...",
  "card_tokenization_mode": "vgs",
  "vgs_vault_id": "tnt...",
  "vgs_environment": "sandbox",
  "supported_payment_methods": ["card"]
}
```

### Delete a payment method

```
DELETE /payment-methods/{payment_method_id}
```

Removes a saved payment method. Returns `204 No Content` on success.

## Subscriptions

### List subscriptions

```
GET /subscriptions
```

### Get a subscription

```
GET /subscriptions/{subscription_id}
```

### Get outstanding payment breakdown

```
GET /subscriptions/{subscription_id}/payment-breakdown
```

Aggregated totals (subtotal, discount, tax, amount due) across all outstanding invoices on the subscription, so the portal can show a single "amount due" figure before collecting.

### Collect payment on a subscription

```
POST /subscriptions/{subscription_id}/collect
```

Attempts payment on **all** outstanding invoices for the subscription using the given payment method.

```json
{ "payment_method_id": "pm_abc123" }
```

The response reports an overall `payment_status` (`paid`, `partially_paid`, or `failed`) plus a per-invoice breakdown in `invoice_results`.

### Update the subscription payment method

```
PUT /subscriptions/{subscription_id}/payment-method
```

Sets the default payment method used for the subscription's future charges.

```json
{ "payment_method_id": "pm_abc123" }
```

### Cancel a subscription

```
POST /subscriptions/{subscription_id}/cancel
```

Cancels the subscription. Returns the updated subscription.

### Undo a scheduled cancellation

```
POST /subscriptions/{subscription_id}/undo-cancel
```

Reverses a pending cancellation (a subscription set to cancel at period end), keeping it active. Returns the updated subscription.

## Changing a subscription (preview then apply)

Plan and quantity changes use a **change request** lifecycle so the customer sees the exact proration before anything is charged: preview to compute the cost, then apply to collect payment and commit the change.

### Preview changes

```
POST /subscriptions/{subscription_id}/change-requests/preview
```

Creates (or reuses) a change-request draft, applies the requested changes to it, and returns a full proration preview alongside a `change_request_id` to apply later.

| Field               | Type      | Description                                            |
| ------------------- | --------- | ------------------------------------------------------ |
| `change_request_id` | string    | Reuse an existing draft instead of creating a new one. |
| `item_changes`      | object\[] | Add, update, or remove subscription items.             |
| `coupon_changes`    | object\[] | Apply or remove coupons.                               |
| `promotion_code`    | string    | Promotion code to apply to the preview.                |

The response includes prorated line items, discount and tax breakdowns, `total_due_atom` (the amount that will be charged), and the `change_request_id`.

### Apply a change request

```
POST /subscriptions/{subscription_id}/change-requests/{request_id}/apply
```

Applies a previously previewed change request using the **charge-first** pattern: payment is collected before the change is committed. Returns the updated subscription(s) — a billing-cycle change may split into new subscriptions, which are included in the response.

```json
{ "payment_method_id": "pm_abc123" }
```

Notable error responses:

| Status                                 | Meaning                                                                 |
| -------------------------------------- | ----------------------------------------------------------------------- |
| `402`                                  | Payment failed — the change was not applied.                            |
| `409` (`STALE_PREVIEW_PERIOD_CHANGED`) | The billing period moved since the preview; re-preview before applying. |
| `409`                                  | The change request is in a state that cannot be applied.                |
| `404`                                  | Subscription, change request, or payment method not found.              |

### Cancel a change request

```
DELETE /subscriptions/{subscription_id}/change-requests/{request_id}
```

Discards a previewed change request without applying it. Returns `204 No Content`.

## Invoices

### List invoices

```
GET /invoices
```

Returns the customer's invoices, paginated.

### Get an invoice

```
GET /invoices/{invoice_id}
```

Returns a single invoice with its line items.

### Download the invoice PDF

```
GET /invoices/{invoice_id}/pdf
```

Returns the PDF URL for a finalized invoice, triggering generation if it isn't ready yet.

```json
{ "pdf_url": "https://...", "status": "available" }
```

When `status` is `generating`, the PDF is still being produced — poll again shortly.

### Pay an invoice

```
POST /invoices/{invoice_id}/collect
```

Collects payment on a single invoice. The response reports `payment_status` (`paid`, `failed`, `no_payment_method`, or `already_paid`) and, on success, the invoice PDF URL.

---

# Security

Portal sessions are designed to be safe to expose in the browser:

* **Token-scoped** — each session is bound to exactly one customer and one account. The token cannot reach any other customer's data.
* **Time-limited** — sessions expire after `expires_in_hours` (default 1 hour, max 30 days). Expired tokens return `401` on every endpoint except session-status.
* **No secret key in the browser** — customer-facing endpoints authenticate solely via the URL token, so your secret key never leaves your server.
* **Actor attribution** — actions taken in the portal (e.g. a self-service cancel) are attributed to the customer in the event log, not to your integration.

---

# Webhooks

Actions taken in the portal emit the same webhook events as any other change, so you can keep your systems in sync:

| Event                             | Fires when                              |
| --------------------------------- | --------------------------------------- |
| `customer.updated`                | The customer updates their information. |
| `customer.subscription.updated`   | A subscription's plan or items change.  |
| `customer.subscription.cancelled` | A subscription is cancelled.            |
| `invoice.paid`                    | An invoice is paid from the portal.     |
| `invoice.payment_failed`          | A portal payment attempt fails.         |

See [Webhooks](/guides/integration/webhooks) for the full event list, setup, and signature verification.

---

# Best practices

#### Link from your app

Add a "Manage billing" button that generates a fresh portal link on demand rather than reusing an old one.

#### Keep sessions short-lived

Set `expires_in_hours` to the shortest window your flow needs.

#### [Use webhooks](/guides/integration/webhooks)

Listen for portal-driven changes to sync subscriptions and payment methods.

#### Preview before applying

For plan changes, always preview so the customer sees the exact proration before paying.