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
- Checkout timed out. Please try again.
- Invalid returnUrl: must be a valid absolute URL
- Invalid returnUrl: must use a trusted worldmonitor.app…
- userId is required
- a pull_request payload is required
AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-09-15).
Data as JSON: /api/errors/2b23e9e08f03fd74.
Report an issue: GitHub.
Appendix: source
Thrown at convex/payments/checkout.ts:88
retryAfterSeconds: Math.max(1, Math.ceil((row.windowStart + CHECKOUT_ADMISSION_WINDOW_MS - now) / 1000)),
};
}
await ctx.db.patch(row._id, { count: row.count + 1 });
} else if (row) {
await ctx.db.patch(row._id, { windowStart: now, count: 1 });
} else {
await ctx.db.insert("checkoutAdmissions", { userId, windowStart: now, count: 1 });
}
return null;
},
});
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 e586b8b4b8)