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-configto 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,
}),
});2. Request a magic link for a customer
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.
3. Customer follows the 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_idsif you only want certain plans available in the portal.