Portal sessions
A portal session is a short-lived, signed URL that drops one of your customers into a self-serve billing page where they can manage their own subscriptions, update payment methods, and download invoices — without your support team in the loop.
This is not the merchant dashboard. When we say "portal" elsewhere in these docs (e.g. Portal → API keys), we mean the dashboard you use to run your business, at plugipay.com. The customer portal documented on this page is a different surface — hosted at
plugipay.com/portal/<session id>(or behind a custom domain likebilling.yourbrand.comif you've set one up), and the only humans who see it are your paying customers.
You mint a portal session from your server, hand the URL to the customer (usually as the href of a "Manage billing" button), and they take it from there. Plugipay handles auth, payment-method updates, plan changes, cancellation flows, and invoice downloads inside the portal — no extra UI to build on your side.
Endpoints
| Method | Path | What |
|---|---|---|
POST |
/v1/portal-sessions |
Mint a new portal URL for one customer. |
There is no GET, LIST, or DELETE for portal sessions. They're write-once, short-lived tokens; once minted, the URL is the only thing that matters, and it expires on its own.
Create a portal session
POST /v1/portal-sessions
Mints a fresh URL bound to a single customer. The URL is valid for 15 minutes from creation; after that it 410s and you'll need to mint a new one.
Requires the plugipay:portal:create scope. Rate-limit class: mutating_light (see Rate limits).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
customerId |
string | yes | The customer to bind this session to. Must be a cus_... ID belonging to the workspace making the request. |
returnUrl |
string | yes | Where to send the customer when they click "Back to <your brand>" or finish a flow. Must be https://. |
Unknown fields are rejected (the schema is strict) — this is intentional, so a typo doesn't silently no-op a future feature flag.
Feature toggles are configured per-workspace, not per-session. Cancel-subscription, update-payment-method, view-invoices, and change-plan are all enabled by default. To disable any of them, head to Portal → Settings → Customer portal in the merchant dashboard — the toggles there apply to every portal session you subsequently mint. If you have a use case for per-session overrides, tell us.
Response
201 Created. The envelope's data is the portal session object.
The interesting field is url — that's the thing you send to the customer.
Code samples
Node
import { PlugipayClient } from '@plugipay/node';
const plugipay = new PlugipayClient({
keyId: process.env.PLUGIPAY_KEY_ID,
secret: process.env.PLUGIPAY_KEY_SECRET,
});
// Inside your "Manage billing" route handler:
app.post('/billing/manage', async (req, res) => {
const session = await plugipay.portalSessions.create({
customerId: req.user.plugipayCustomerId,
returnUrl: 'https://app.acme.com/account',
});
res.redirect(303, session.url);
});
Python
from plugipay import Plugipay
plugipay = Plugipay(key_id=os.environ["PLUGIPAY_KEY_ID"],
secret=os.environ["PLUGIPAY_KEY_SECRET"])
@app.post("/billing/manage")
def manage_billing():
session = plugipay.portal_sessions.create(
customer_id=current_user.plugipay_customer_id,
return_url="https://app.acme.com/account",
)
return redirect(session.url, code=303)
Go
session, err := client.PortalSessions.Create(ctx, &plugipay.PortalSessionCreate{
CustomerID: user.PlugipayCustomerID,
ReturnURL: "https://app.acme.com/account",
})
if err != nil { return err }
http.Redirect(w, r, session.URL, http.StatusSeeOther)
curl
plugipay_curl POST /v1/portal-sessions '{
"customerId": "cus_01HXxxxxxxxxxxxxxxxxxxxxxx",
"returnUrl": "https://app.acme.com/account"
}'
(Using the plugipay_curl shell function from Authentication.)
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION_ERROR |
Body fails the schema. The two common cases: returnUrl isn't an https:// URL, or you sent a field that isn't customerId or returnUrl. |
404 |
not_found |
The customerId doesn't exist in this workspace. (We don't distinguish "wrong workspace" from "doesn't exist" — both 404 to avoid leaking the existence of other tenants' customers.) |
401 |
invalid_signature / invalid_key |
Standard auth errors — see Authentication. |
403 |
insufficient_scope |
The key is missing plugipay:portal:create. |
429 |
— | Mutating rate limit exceeded. Honour Retry-After. |
The portal session object
{
"id": "ps_01HXxxxxxxxxxxxxxxxxxxxxxx",
"arn": "arn:plugipay:acc_01HXxxxx:portal-session/ps_01HXxxxx",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"customerId": "cus_01HXxxxxxxxxxxxxxxxxxxxxxx",
"url": "https://plugipay.com/portal/ps_01HXxx...#t=eyJhbGciOi...",
"returnUrl": "https://app.acme.com/account",
"expiresAt": "2026-05-12T10:57:00.000Z",
"createdAt": "2026-05-12T10:42:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
id |
string | ps_<ulid>. The session identifier. Useful for log correlation; not used in any other API call. |
arn |
string | Plugipay ARN for the resource. Comes back so platform-admin tooling can reference it uniformly with other resources. Most integrations ignore it. |
accountId |
string | The merchant workspace that minted the session. Matches the key (or the X-Plugipay-On-Behalf-Of header if you're a platform partner). |
customerId |
string | The customer the session is bound to. The portal will only let this customer see and modify their own subscriptions, payment methods, and invoices. |
url |
string | The URL you give the customer. Includes the session ID in the path and a signed token in the URL fragment (#t=...). The fragment is never sent to Plugipay's logs — it stays in the browser. |
returnUrl |
string | Echoes the returnUrl you submitted. The portal renders a "Back to <your brand>" link pointing here. |
expiresAt |
string | ISO 8601, UTC. Always createdAt + 15 minutes. After this, clicking the URL returns 410 Gone. |
createdAt |
string | ISO 8601, UTC. When the session was minted. |
The customerId binding is enforced server-side every time the portal loads a page — even if a customer somehow guesses another ps_... ID, they can't use it without the signed fragment, which is unique per session.
Expiry: mint on click, not ahead of time
The 15-minute TTL is short on purpose. Portal sessions are credentials in URL form, and the design assumes you mint them lazily — in the request handler for the "Manage billing" button, immediately before redirecting.
Don't mint portal sessions ahead of time and store them. Two failure modes:
- Expired before use. A customer opens an email containing a portal URL 20 minutes after generation — they hit a 410 and write your support address instead.
- Leaked credential. Anyone who can read the stored URL can act as the customer for 15 minutes. Treat it like a one-time password: produce on demand, hand straight to the customer, never log it.
The right shape is always: customer clicks "Manage billing" → your server calls
POST /v1/portal-sessions→ you303 See Otherto the returnedurl.
If you're sending portal links in email (e.g. "your card on file is expiring — click here to update it"), don't put the portal URL directly in the email. Instead, link to a route on your server that mints a fresh session at the moment the customer clicks. The customer never sees the 15-minute TTL because the timer starts at click, not at send.
Events
Portal sessions don't emit webhook events on creation. Once the customer is inside the portal, the actions they take (cancelling a subscription, attaching a new payment method, paying an invoice) fire the same events you'd see from any API-driven equivalent — subscription.cancelled, payment_method.attached, invoice.paid, and so on. See Webhooks for the full event catalog.
This means your existing event handlers cover the customer-portal flow automatically: there's nothing portal-specific to subscribe to.
Related
- Customers — you'll need a
customerIdto bind the session to. - Subscriptions — the resource most customers visit the portal to manage.
- Webhooks — events fired by customer actions inside the portal.
- Portal → Settings — where you toggle which actions customers can take in the portal (the merchant-dashboard side of the configuration).