koala73/worldmonitor · error · ConvexError
INVALID_CHECKOUT_PRODUCT
INVALID_CHECKOUT_PRODUCT
Error message
This product is not available for checkout.
What it means
requireCheckoutProduct validates that the requested Dodo product ID maps to a catalog entry that is currently offered (currentForCheckout) and purchasable self-serve (selfServe). Anything else — unknown product ID, retired plan, or internal/admin-only product — is rejected so checkout can never be created for an unintended product.
Solutions
- Fetch the current checkout-eligible product IDs from PRODUCT_CATALOG (or the pricing endpoint) and use one of those.
- If the plan was superseded, switch to the replacement product's dodoProductId.
- For non-self-serve products, use the internal/admin grant path instead of checkout.
- Clear stale client caches/pricing pages that reference retired product IDs.
Example fix
// before
createCheckout({ productId: "prod_old_pro_monthly" }); // retired
// after
const sku = await getCurrentSelfServeProductId("pro_monthly");
createCheckout({ productId: sku }); Defensive patterns
Strategy: validation
Validate before calling
import { PRODUCT_CATALOG } from "./catalog";
const ok = Object.values(PRODUCT_CATALOG).some(p => p.dodoProductId === productId && p.currentForCheckout && p.selfServe);
if (!ok) throw new Error(`product ${productId} is not checkout-eligible`); Try / catch
try {
await createCheckout({ productId });
} catch (e) {
if (isConvexError(e) && e.data?.code === "INVALID_CHECKOUT_PRODUCT") {
refreshPricingAndPromptUserToRechoosePlan();
}
} Prevention
- Never hardcode dodoProductId in clients; fetch current self-serve SKUs from the pricing endpoint.
- Rotate plan IDs behind stable plan keys so clients survive catalog changes.
- Verify product IDs per environment (staging vs production Dodo).
When it happens
Trigger: Calling createCheckout or internalCreateCheckout with a productId that is not in PRODUCT_CATALOG, has dodoProductId mismatch, currentForCheckout=false (retired/superseded plan), or selfServe=false (comp/admin-only SKU).
Common situations: Client hardcodes an old dodoProductId after the plan was rotated; attempting to buy an enterprise/annual-only SKU through the self-serve checkout; typo'd product ID; stale cached pricing page pointing at a retired product.
Understand the failure class
Background: Invalid enum value errors: "Unknown type", "Invalid scope", "must be one of" — when a string is not on the library's allowed list — this error's family across 23 libraries.
Related errors
- Invalid returnUrl: must be a valid absolute URL
- Invalid returnUrl: must use a trusted worldmonitor.app origi
- userId is required
- ${label} HTTP 400
- Could not resolve ${JSON.stringify(echoCountryInput(raw))} t
AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15).
Data as JSON: /api/errors/2b23e9e08f03fd74.
Report an issue: GitHub.
Appendix: source
Thrown at convex/payments/checkout.ts:53
import { recordTerminalCheckoutRateLimit } from "./checkoutRateLimitAlarm";
// MCP paid-funnel campaign marker (#6716). Imported, never re-declared: a
// second copy of this normalisation is exactly the drift that produced the
// display-vs-enforcement divergence documented in
// docs/solutions/security-issues/mcp-quota-credential-class-vs-plan-family-scoping-bypass.md.
// The Convex runtime imports from `shared/` elsewhere (convex/apiKeys.ts,
// convex/companyMonitoring/*), so there is no module-boundary reason to fork it.
import { normalizeCheckoutAttributionSource as normalizeAttributionSource } from "../../shared/mcp-attribution";
const ACTIVE_SUBSCRIPTION_EXISTS = "ACTIVE_SUBSCRIPTION_EXISTS";
const PAYMENT_IN_PROGRESS = "PAYMENT_IN_PROGRESS";
function requireCheckoutProduct(productId: string): void {
const allowed = Object.values(PRODUCT_CATALOG).some(
(entry) => entry.dodoProductId === productId && entry.currentForCheckout && entry.selfServe,
);
if (!allowed) {
throw new ConvexError({
code: "INVALID_CHECKOUT_PRODUCT",
message: "This product is not available for checkout.",
});
}
}
// RFC 5321 maximum forward-path length. A value beyond it is not an address we
// could deliver to anyway, and it keeps the stamped metadata value small.
const MAX_LOGIN_EMAIL_LENGTH = 254;
/**
* Normalizes the authenticated login email for stamping into checkout metadata
* (#6335).
*
* This is a shape guard, not a trust boundary — it keeps an unusable value out
* of a field the webhook later hands to Resend as a recipient. What makes that
* the right level: `createCheckout` reads the email from the Clerk JWT `email`
* claim via `resolveUserIdentity`, and `internalCreateCheckout` receives it fromView on GitHub (pinned to 7d06c8633d)