Checkout is not where a billing integration starts.
For a SaaS app, it usually starts one step earlier:
Can this billing customer be tied back to the right user in my app?
That tiny mapping is easy to ignore when you are just trying to get checkout working. But later, when a webhook comes in, or a subscription changes, or support asks why someone paid but still has no access, this is the field you wish you tested earlier.
In Polar, that mapping can live in customer metadata / external customer identifiers like external_id.
The bug is not dramatic
The bug usually looks boring.
Your app creates a customer:
POST /v1/customers/
You get back a Polar customer ID.
Then later your app receives something like:
customer.created
subscription.active
order.paid
benefit.granted
Now your code has to answer one question:
Which local user does this belong to?
If you only stored the provider customer ID, you can probably make it work. But if your local user ID never made it into the customer record, or your webhook handler assumes the mapping exists before it actually does, the access-control code gets messy fast.
That is where a field like external_id matters.
It is not just extra metadata. It is the bridge between billing state and product state.
The small workflow I want to test
Before touching hosted checkout, I want this workflow to pass:
POST /v1/customers/
email: buyer@example.com
name: Test Buyer
external_id: user_123
GET /v1/customers/{id}
verify id exists
verify email survived
verify external_id is still user_123
customer.created
verify webhook can be reconciled back to user_123
That is it.
No payment.
No subscription access yet.
No real customer.
Just proving that the customer record your app creates can be read back and matched to the user who started the flow.
Why this catches real billing bugs
Most checkout demos focus on the happy path:
- click checkout
- pay
- redirect back
- unlock access
But production systems depend on slower, less visible steps.
Your webhook might arrive before your app finishes saving local state. Your user may open checkout in one tab and close it. A retry may deliver the same event twice. Your support tooling may need to search by internal user ID, not provider ID.
If the customer mapping is weak, all of those paths become harder.
The failure usually shows up as:
- user paid but access is not granted
- webhook event cannot find a local user
- customer exists in Polar, but your app has no clean link to it
- support sees a billing ID but cannot connect it to an account
- tests pass because they only checked
201 Created
The endpoint returned success. The integration still does not know who owns the customer.
Why a static mock is not enough
A static mock can return this:
{
"id": "cus_test_123",
"email": "buyer@example.com",
"external_id": "user_123"
}
But the next request is the important one.
If you call:
GET /v1/customers/cus_test_123
does the mock remember the customer you just created?
Does it preserve external_id?
Can your webhook handler use that same value to reconcile the event?
For billing integrations, state matters more than the first response. A fake 201 is not enough.
Testing it in FetchSandbox
We added Polar to FetchSandbox so this kind of pre-checkout state can be tested without a Polar token.
The useful test is not "can I call the endpoint?"
It is:
POST /v1/customers/
GET /v1/customers/{id}
inspect customer.created
Then check whether the same customer state flows through each step.
That gives you a simple confidence check before wiring checkout, subscriptions, orders, and benefits.
It does not replace Polar's real sandbox. You still need that before launch for hosted checkout, real signatures, account configuration, and final payment behavior.
But it helps catch the boring mapping problem earlier.
And boring mapping problems are exactly the ones that become painful after someone pays.
Try the Polar sandbox — no Polar token needed.
Curious how others handle this. Do you store provider customer IDs only, or always write your internal user ID into the billing customer too?
Top comments (0)