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

Customers & portal

Create, update and look up customers with the unified API, and mint short-lived provider-hosted customer portal sessions on demand.

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.

const customer = await client.customers.get({ id: 'CUSTOMER_ID' });

// The provider-hosted page where a customer updates payment details, reads
// invoices, and cancels. Mint the link when the customer clicks — it expires.
const session = await client.customerPortal.createSession({ customerId: customer.id });

redirect(session.url);

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 { 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

customers.list({ email }) is an exact match on all five providers, and it is never a primary key: emails are case-sensitive on Stripe and may repeat there, so a lookup can return zero, one, or several customers. Store the provider Customer.id against your own user record the first time you see it, and look up by that ID afterwards.

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) });
}
Per-provider filter caveats
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.

Creating and updating customers

customers.create provisions a customer before any checkout — useful when you want a provider ID on your user record from the moment they sign up. customers.update edits one in place. All five providers support both, so neither operation is capability-gated.

const customer = await client.customers.create({
  email: '[email protected]',
  name: 'Ada Lovelace',
  metadata: { userId: '42' }, // requires the customerMetadata capability
});

await client.customers.update({ id: customer.id, name: 'Ada L.' });
PropType
email?string

The customer email.

Typestring
name?string

Display name. Required — see below.

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

Provider metadata. Requires the customerMetadata capability.

TypeRecord<string, string | number | boolean>
signal?AbortSignal

Abort the request.

TypeAbortSignal

update takes the same fields plus the id to update, all of them optional; an omitted field keeps its stored value, and a field passed as an empty string is rejected with validation before the request goes out — no provider reads '' as “clear this field”.

name is required on create even though only two providers demand it: Lemon Squeezy and Dodo Payments both reject a customer without one, so a params type that made it optional would compile everywhere and fail on two providers at runtime.

Both calls are writes: the client replays them once when the provider answered with a rate limit, and never after a transport failure. No provider offers idempotency keys on customers, so a replayed create would risk a duplicate.

Metadata is the one field that is not portable

Metadata is the only customer field gated by a capability. Four providers store it; Lemon Squeezy has nowhere to put it, so metadata is rejected with unsupported there before the request goes out — branch on client.capabilities.customerMetadata to keep your own code provider-agnostic.

Stripe is the odd one out on update: it merges metadata key by key, where everyone else replaces the whole object. If you need “these keys and nothing else” on Stripe, read the customer first and send the keys you want removed explicitly.

Metadata wire fields and update semantics
Provider Wire field On update
Polar metadata Replaces. Capped at 50 pairs, 40-character keys, 500-character values.
Lemon Squeezy No metadata field at all — customerMetadata is false here.
Stripe metadata Merges key by key — keys you leave out keep their stored value.
Paddle custom_data Replaces the whole object. The SDK never reads-then-merges.
Dodo Payments metadata Replaces.

Duplicate emails

Whether an email may repeat is a provider policy, so a create-or-get flow must look the address up with customers.list({ email }) first rather than relying on the error. Paddle rejects a taken address with conflict (the existing customer’s ID is in the message), Polar requires uniqueness within the organization and answers validation, and Stripe allows duplicates outright.

Customer portal sessions

customerPortal.createSession mints a session and returns its 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, never in a cached response, and never in an email. Lemon Squeezy’s lasts around 24 hours and Polar’s around an hour; Stripe, Paddle, and Dodo Payments mint short-lived sessions of their own.

// 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 — false on Lemon Squeezy and Paddle, where passing it throws unsupported. See the capability matrix. 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 8, 2026

Was this page helpful?