Skip to main content

Your plan slug is not Polar's product ID

00:00:55:46

I used to stash the Polar product id on the plan row and treat it like a slug. Then a second provider showed up with pdt_* ids, Polar kept prod_*, and a leftover Dodo id almost went out on a Polar checkout.

ts
const mapped = await this.planRepository.findExternalProductId(
  planId,
  providerId,
);
if (mapped) {
  return mapped;
}
if (providerId === 'dodo' && fallback) {
  return fallback;
}
throw new ApiException(
  ApiErrorCode.BAD_REQUEST,
  HttpStatus.BAD_REQUEST,
  'error.plan_not_sellable',
);

Polar fail-closes. No mapping, no sell. Dodo can still use the legacy column. The UI does the same cut: Polar only offers rows that list Polar in billingProviders.

ts
export function canOfferInAppPlanChange(offer, billingProvider) {
  const provider = billingProvider ?? DEFAULT_BILLING_PROVIDER;
  if (provider !== "polar") return true;
  if (offer.billingProviders == null) {
    return offer.slug != null && isKnownPricingPlanSlug(offer.slug);
  }
  return offer.billingProviders.includes("polar");
}

builder and agency are marketing names. Polar never sees them. The mapping table is the only join between those two ids.

Keep the catalog in your database. Polar's id is a foreign key.