Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
id?string

Provider customer identifier, coerced to a string.

Typestring
email?string

The customer email. Empty string when the provider has none on file.

Typestring
name?string

Display name, when set.

Typestring
metadata?Record<string, string | number | boolean>

Provider metadata, where available.

TypeRecord<string, string | number | boolean>
createdAt?Date

When the customer was created.

TypeDate
raw?unknown

The untouched provider payload.

Typeunknown

Reading 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);
PropType
customerId?string

The provider customer to open the portal for.

Typestring
returnUrl?string

Where the portal sends the customer back to. Requires the portalReturnUrl capability.

Typestring
signal?AbortSignal

Abort the request.

TypeAbortSignal

Mint 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,
});

Last updated on August 6, 2026

Was this page helpful?