Customers & portal
Look up customers, understand the per-provider email-filter caveats, and mint short-lived customer portal sessions that expire quickly.
Customers are the provider-side identity a subscription belongs to. revenue-sdk normalizes them into
one shape and gives you a single call for the provider’s self-service billing portal.
The Customer model
id?string
Provider customer identifier, coerced to a string.
stringemail?string
The customer email. Empty string when the provider has none on file.
stringname?string
Display name, when set.
stringmetadata?Record<string, string | number | boolean>
Provider metadata, where available.
Record<string, string | number | boolean>createdAt?Date
When the customer was created.
Dateraw?unknown
The untouched provider payload.
unknownReading customers
const customer = await client.customers.get({ id: 'CUSTOMER_ID' });
const { items, cursor } = await client.customers.list({ limit: 50 });
for await (const entry of client.customers.listAll()) {
console.log(entry.id, entry.email);
}
The email filter and its caveats
customers.list accepts an exact-match email filter:
const { items } = await client.customers.list({ email: '[email protected]' });
const customer = items[0];
It is supported by all five providers, but it does not behave identically:
| Provider | Caveat |
|---|---|
| Polar | Exact match on the organization’s customers. |
| Lemon Squeezy | Always scoped to the store configured in the factory (storeId). Customers in your other stores are invisible. |
| Stripe | Case-sensitive, and emails are not unique — several customers can share one address. |
| Paddle | Exact match. |
| Dodo Payments | Exact match. |
A defensive lookup that survives duplicates:
const { items } = await client.customers.list({ email, limit: 100 });
if (items.length > 1) {
// Ambiguous — prefer the customer that already has a subscription.
logger.warn(`Multiple customers share ${email}`, { ids: items.map((c) => c.id) });
}
Customer portal sessions
The customer portal is the provider-hosted page where a customer updates their payment method, views
invoices, and cancels. customerPortal.createSession mints a session and returns its URL:
const session = await client.customerPortal.createSession({
customerId: customer.id,
returnUrl: 'https://example.com/account', // requires the portalReturnUrl capability
});
redirect(session.url);
customerId?string
The provider customer to open the portal for.
stringreturnUrl?string
Where the portal sends the customer back to. Requires the portalReturnUrl capability.
stringsignal?AbortSignal
Abort the request.
AbortSignalMint one per click — the URLs expire
Portal URLs are short-lived, signed links. Generate one when the customer clicks the button, never at page render and never in a cached response or an email.
| Provider | Lifetime |
|---|---|
| Lemon Squeezy | ~24 hours (the signed URL on the customer record) |
| Polar | ~1 hour (customer session) |
| Stripe | Short-lived, single-use billing portal session |
| Paddle | Short-lived portal session |
| Dodo Payments | Short-lived portal session |
// A route that redirects straight into a freshly minted portal session.
export async function GET(request: Request): Promise<Response> {
const customerId = await resolveCustomerId(request);
const session = await client.customerPortal.createSession({ customerId });
return Response.redirect(session.url, 302);
}
returnUrl support
returnUrl is gated by the portalReturnUrl capability:
| Provider | portalReturnUrl |
|---|---|
| Polar | true |
| Lemon Squeezy | false |
| Stripe | true |
| Paddle | false |
| Dodo Payments | true |
Passing it to Lemon Squeezy or Paddle throws RevenueError with code unsupported. In
provider-agnostic code, gate it:
const session = await client.customerPortal.createSession({
customerId,
returnUrl: client.capabilities.portalReturnUrl ? 'https://example.com/account' : undefined,
});