Summary
When createCustomerOnSignUp: true is enabled in @polar-sh/better-auth (1.8.3), every signup fails with INTERNAL_SERVER_ERROR because onAfterUserCreate calls customers.update({ customerUpdate: { externalId: user.id } }) and the Polar API rejects that update with HTTP 422:
PolarRequestValidationError: Customer external ID cannot be updated.
{ loc: ["body", "external_id"], msg: "Customer external ID cannot be updated.", type: "value_error" }
The adapter source assumes externalId is mutable; the Polar API treats it as immutable after creation.
Reproduction
betterAuth({
...,
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true,
use: [checkout({...}), portal()],
}),
],
});
Sign up any new user. The first request to a Polar-adapter route (or any flow that triggers onAfterUserCreate) attempts to update the just-created customer's externalId to user.id, gets 422, throws APIError("INTERNAL_SERVER_ERROR") from the catch block in packages/polar-betterauth/src/hooks/customer.ts. Signup is broken for every new user.
Verified directly against sandbox-api.polar.sh with the @polar-sh/sdk:
await client.customers.update({ id, customerUpdate: { externalId: "anything" } });
// → HTTP 422 PolarRequestValidationError: "Customer external ID cannot be updated."
Why two paths both fail
onBeforeUserCreate (source) creates customers via customers.create without an externalId (only email + name from the getCustomerCreateParams defaults).
onAfterUserCreate then lists customers by email, finds the just-created one, and calls customers.update to retroactively set externalId. This is the call Polar rejects.
Even setting getCustomerCreateParams to return { externalId: "..." } doesn't help — the create call accepts it, but then onAfterUserCreate still tries to update from (provided value) → user.id, hits the same 422.
Impact
The adapter's customer-creation contract is non-functional in current Polar API behaviour. Anyone enabling createCustomerOnSignUp: true against the live API gets:
- 5xx on every signup
- A trail of half-created Polar customers (the
customers.create succeeds; only the post-create update fails)
Suggested fixes
Two low-risk paths, depending on how Polar wants to think about externalId semantics:
(a) Set externalId at create time, drop the post-create update.
// onBeforeUserCreate
await options.client.customers.create({
...params,
email: user.email,
name: user.name,
externalId: user.id, // <— set here, where Polar API allows it
});
// onAfterUserCreate — drop the update entirely; if a customer already
// existed pre-signup with no externalId, leave it. Optionally surface
// that as a warning so consumers can attach externalId via their own
// migration script.
(b) Make externalId binding pluggable.
For B2B SaaS, "one Polar customer per Better Auth user" doesn't fit — billing typically belongs to the org, not to individual users (multi-manager orgs are the norm). Exposing an externalIdResolver: (user) => Promise<string> option (or accepting it from getCustomerCreateParams) lets consumers route externalId to whatever entity matches their billing model. Combined with (a) — set at create time, never updated — this also works around the Polar API constraint.
I'd be happy to send a PR if either direction sounds right; let me know which (or whether there's a third path I'm missing).
Adjacent observation (not blocking but related)
onAfterUserCreate also assumes externalId === user.id is the One True invariant, and the read endpoints (/customer/state, /customer/portal, /customer/benefits/list, /customer/subscriptions/list without referenceId) all unconditionally bind to ctx.context.session.user.id. For B2B applications this is a structural mismatch, separate from the 422 bug — even if the update call worked, the adapter can't model "billing belongs to the org, not the user." The pluggable resolver in option (b) would address both.
Versions
@polar-sh/better-auth: 1.8.3
@polar-sh/sdk: 0.46.7
better-auth: 1.6.5
- Polar API: sandbox (
sandbox-api.polar.sh); same behaviour expected on prod (the 422 message is from Polar core validation, not env-specific)
Filed from a B2B SaaS migrating its billing surface onto the adapter — happy to share more context if useful.
Summary
When
createCustomerOnSignUp: trueis enabled in@polar-sh/better-auth(1.8.3), every signup fails withINTERNAL_SERVER_ERRORbecauseonAfterUserCreatecallscustomers.update({ customerUpdate: { externalId: user.id } })and the Polar API rejects that update with HTTP 422:The adapter source assumes externalId is mutable; the Polar API treats it as immutable after creation.
Reproduction
Sign up any new user. The first request to a Polar-adapter route (or any flow that triggers
onAfterUserCreate) attempts to update the just-created customer's externalId touser.id, gets 422, throwsAPIError("INTERNAL_SERVER_ERROR")from the catch block inpackages/polar-betterauth/src/hooks/customer.ts. Signup is broken for every new user.Verified directly against
sandbox-api.polar.shwith the @polar-sh/sdk:Why two paths both fail
onBeforeUserCreate(source) creates customers viacustomers.createwithout an externalId (onlyemail+namefrom thegetCustomerCreateParamsdefaults).onAfterUserCreatethen lists customers by email, finds the just-created one, and callscustomers.updateto retroactively set externalId. This is the call Polar rejects.Even setting
getCustomerCreateParamsto return{ externalId: "..." }doesn't help — the create call accepts it, but thenonAfterUserCreatestill tries to update from(provided value) → user.id, hits the same 422.Impact
The adapter's customer-creation contract is non-functional in current Polar API behaviour. Anyone enabling
createCustomerOnSignUp: trueagainst the live API gets:customers.createsucceeds; only the post-create update fails)Suggested fixes
Two low-risk paths, depending on how Polar wants to think about externalId semantics:
(a) Set externalId at create time, drop the post-create update.
(b) Make externalId binding pluggable.
For B2B SaaS, "one Polar customer per Better Auth user" doesn't fit — billing typically belongs to the org, not to individual users (multi-manager orgs are the norm). Exposing an
externalIdResolver: (user) => Promise<string>option (or accepting it fromgetCustomerCreateParams) lets consumers route externalId to whatever entity matches their billing model. Combined with (a) — set at create time, never updated — this also works around the Polar API constraint.I'd be happy to send a PR if either direction sounds right; let me know which (or whether there's a third path I'm missing).
Adjacent observation (not blocking but related)
onAfterUserCreatealso assumesexternalId === user.idis the One True invariant, and the read endpoints (/customer/state,/customer/portal,/customer/benefits/list,/customer/subscriptions/listwithoutreferenceId) all unconditionally bind toctx.context.session.user.id. For B2B applications this is a structural mismatch, separate from the 422 bug — even if the update call worked, the adapter can't model "billing belongs to the org, not the user." The pluggable resolver in option (b) would address both.Versions
@polar-sh/better-auth: 1.8.3@polar-sh/sdk: 0.46.7better-auth: 1.6.5sandbox-api.polar.sh); same behaviour expected on prod (the 422 message is from Polar core validation, not env-specific)Filed from a B2B SaaS migrating its billing surface onto the adapter — happy to share more context if useful.