Easy Labs
BillingGuides

Launch the customer portal

Email a magic link that drops a customer into their hosted billing portal.

Goal

Let a customer manage their own subscriptions, payment methods, and invoices in a hosted, white-label page. You configure what the portal exposes once, then mint a magic link per session — no portal-specific UI to build, and your API key never leaves the server. The portal is also the recommended target for failed-payment recovery emails (see Configure dunning).

Prerequisites

  • A sandbox or production API key
  • A Customer with a known email
  • One configuration pass through PATCH /customer-portal-config to enable the sections you want (payment methods, subscriptions, cancellations) — see the config reference

Implementation

1. Configure the portal once

Toggle features and define cancellation semantics. Done once per environment, not per customer.

// PATCH /v1/api/customer-portal-config
await fetch(`${EASY_API_URL}/v1/api/customer-portal-config/`, {
  method: "PATCH",
  headers: {
    "x-easy-api-key": process.env.EASY_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    payment_methods_enabled: true,
    accepted_payment_methods: ["card", "bank_account"],
    subscriptions_enabled: true,
    allow_plan_switch: true,
    allow_quantity_updates: true,
    cancellations_enabled: true,
    cancellation_mode: "end_of_period",
    cancellation_proration_enabled: false,
    cancellation_reasons_enabled: true,
    cancellation_reasons: ["Too expensive", "Missing features", "Switching tools"],
    return_url: "https://your-app.com/account",
    invoice_history_enabled: true,
  }),
});

When a signed-in user clicks "Manage billing" in your app, call the request-link endpoint server-side. The platform emails the link to the customer.

type RequestLinkBody = {
  company_id: string;
  email: string;
  destination?: "home" | "payment_methods" | "billing_information";
  destination_context?: Record<string, unknown>;
};

async function requestPortalLink(body: RequestLinkBody) {
  const res = await fetch(`${EASY_API_URL}/v1/api/customer-portal/access/request-link`, {
    method: "POST",
    headers: {
      "x-easy-api-key": process.env.EASY_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`request-link failed: ${res.status}`);
  return res.json();
}

await requestPortalLink({
  company_id: process.env.EASY_COMPANY_ID!,
  email: "ada@example.com",
  destination: "payment_methods",
});

The destination controls the landing section. Use payment_methods from a "Update your card" prompt and home from a generic "Manage billing" link.

The customer clicks the email, the portal loads, and a token in the URL is exchanged for a session via POST /customer-portal/access/consume. Everything from that point — payment-method updates, plan switches, cancels — runs against that scoped session, not your API key.

4. React to portal-driven changes

The portal mutates the same records as the merchant API, so your existing webhook handlers will fire. Watch for subscription.updated (plan switches, quantity changes), subscription.deleted (cancellations), invoice.paid (recovered failures), and act in your app accordingly.

Tradeoffs

  • Magic link delivery is asynchronous — if your UI promises "we just emailed you", make sure your transactional email provider is set up. For a same-tab handoff (no email round-trip), use the access-handoff create/exchange pair instead.
  • cancellation_mode: "immediately" vs. "end_of_period" — immediate cancels free the seat now but lose the remaining paid period; end-of-period preserves goodwill and is the conventional SaaS default.
  • Self-serve plan switching is convenient but skips your in-app upsell logic. Restrict to a curated catalog with subscription_product_ids if you only want certain plans available in the portal.

On this page