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 after ExpiresAt. 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

Plugipay — Payments that don't tax your success