> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.paymentkit.com/guides/hosted-pages/customer-portal/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 | > **Note** > > 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. > **Warning** > > 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. > A hosted, token-authenticated portal where your customers manage payment methods, subscriptions, and invoices without signing in.