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
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 { 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.' });
email?string
The customer email.
stringname?string
Display name. Required — see below.
stringmetadata?Record<string, string | number | boolean>
Provider metadata. Requires the customerMetadata capability.
Record<string, string | number | boolean>signal?AbortSignal
Abort the request.
AbortSignalupdate 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:
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, 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,
});