Portal sessions
A portal session is a short-lived, signed URL that lets a customer manage their own subscriptions, invoices, payment methods, and receipts — without you building any of that UI. You create one with the customer's id and a ReturnURL; Plugipay returns a one-time URL you redirect the customer to. They self-serve under your branding, then come back to your app via the return URL. The Go SDK exposes the single portal-session method behind client.PortalSessions. For wire shapes and the portal feature toggles, see API → Portal sessions.
Field on the Client
client.PortalSessions — type *plugipay.PortalSessionsResource. Installed by NewClient; shares the parent *http.Client, base URL, key, and OnBehalfOf default. Safe for concurrent use.
Methods
Create
Signature. func (r *PortalSessionsResource) Create(ctx context.Context, in PortalSessionCreateInput) (*PortalSession, error)
Mints a portal session for one customer. Both fields are required — CustomerID (cus_...) identifies who the session is for; ReturnURL is where the portal sends them when they click "Back to merchant". Auto-keyed for idempotency.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ps, err := client.PortalSessions.Create(ctx, plugipay.PortalSessionCreateInput{
CustomerID: "cus_01HX...",
ReturnURL: "https://example.com/account",
})
if err != nil {
return err
}
// Redirect the customer to the portal:
http.Redirect(w, r, ps.URL, http.StatusSeeOther)
One-time, short-lived. The URL expires at
ps.ExpiresAt(typically 5 minutes from creation). Once the customer follows the link, the portal binds a regular session cookie, so subsequent navigation works even afterExpiresAt. Don't email the URL — it's bearer-style; minted, used immediately, discarded.
There is no Get / List / Delete on portal sessions — they're create-only and disappear after expiry. To "log a customer out", expire their portal session via your dashboard or simply wait for the cookie to time out.
Types
type PortalSession struct {
ID string `json:"id"` // "ps_..."
CustomerID string `json:"customerId"`
URL string `json:"url"` // redirect target
ReturnURL string `json:"returnUrl"`
ExpiresAt string `json:"expiresAt"` // ISO-8601
}
Field rules and the portal's customer-facing feature matrix: API → Portal sessions.
Common patterns
"Manage subscription" button
The most common deployment is a single button in your account UI that mints a session and redirects:
func manageHandler(c *plugipay.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
custID := custIDFromSession(r) // your own user-to-cust mapping
if custID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ps, err := c.PortalSessions.Create(ctx, plugipay.PortalSessionCreateInput{
CustomerID: custID,
ReturnURL: "https://example.com/account",
})
if err != nil {
var pe *plugipay.Error
if errors.As(err, &pe) && pe.Code == "not_found" {
http.Error(w, "customer not found", http.StatusNotFound)
return
}
http.Error(w, "portal error", http.StatusBadGateway)
return
}
http.Redirect(w, r, ps.URL, http.StatusSeeOther)
}
}
Mount this at /account/manage and you're done.
Re-mint on every click
Because portal URLs are one-time, don't cache them. Mint a fresh session each time the customer clicks "Manage" — the call is cheap and the SDK auto-keys it for idempotency at the network level. Caching saves you nothing and breaks the moment a customer opens two tabs.
Errors with errors.As
ps, err := c.PortalSessions.Create(ctx, in)
var pe *plugipay.Error
if errors.As(err, &pe) {
switch pe.Code {
case "not_found":
// unknown CustomerID
case "validation_error":
// ReturnURL is not a valid absolute URL
case "insufficient_scope":
// key lacks plugipay:portal:write
}
}
Context cancellation
ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second)
defer cancel()
ps, err := c.PortalSessions.Create(ctx, in)
A canceled ctx surfaces as *plugipay.Error{Code: "canceled"} — safe to retry on a fresh ctx since the operation is auto-keyed.
Per-merchant portals (platform admin)
If you're a platform admin minting portals on behalf of merchant accounts, use ForMerchant:
merchantClient := c.ForMerchant("acc_01HX...")
ps, err := merchantClient.PortalSessions.Create(ctx, plugipay.PortalSessionCreateInput{
CustomerID: "cus_01HX...",
ReturnURL: "https://merchant.example.com/account",
})
ForMerchant returns a shallow clone that adds X-Plugipay-On-Behalf-Of to every call; the underlying *http.Client is shared. See Authentication.
Embedding the portal in your own UI
The portal is hosted by Plugipay — there's no embed mode in v1. If you want to keep the user inside your own chrome, mint the URL and open it in a new tab; the portal's "Back to merchant" button uses ReturnURL to close the loop. Don't iframe the portal; the response carries X-Frame-Options: DENY.
A working "open in new tab" pattern:
func openPortal(w http.ResponseWriter, r *http.Request, c *plugipay.Client) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
ps, err := c.PortalSessions.Create(ctx, plugipay.PortalSessionCreateInput{
CustomerID: custIDFromSession(r),
ReturnURL: "https://example.com/account?from=portal",
})
if err != nil { http.Error(w, "portal error", 502); return }
// Browsers ignore Location with target=_blank — return JSON for the FE:
json.NewEncoder(w).Encode(map[string]string{"url": ps.URL})
}
The FE then calls window.open(json.url, "_blank", "noopener").
Locking the return URL allowlist
If your ReturnURL is constructed from query params, validate it server-side before passing to Create. Plugipay won't follow an arbitrary URL on its side, but a bad value will trip validation_error and you'll burn a round-trip. The cheap-and-correct check: ensure the URL parses, uses https, and has a hostname your app owns.
Errors
Code |
Status |
Cause |
|---|---|---|
validation_error |
400 | Missing CustomerID, malformed ReturnURL. |
not_found |
404 | CustomerID doesn't exist or is in another workspace. |
rate_limited |
429 | Too many mints. Back off. |
insufficient_scope |
403 | Key lacks plugipay:portal:write. |
Full mechanics: Errors.
Next
- Customers — you need a
cus_*id to mint a portal session. - Subscriptions — the portal's primary self-service surface.
- API → Portal sessions — HTTP-level reference.