Skip to content

[better-auth] createCustomerOnSignUp 5xxs every signup: customers.update({externalId}) is rejected by Polar API (422) #405

@jarkkosyrjala

Description

@jarkkosyrjala

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions