For a long stretch, no customer could pay us. The billing table was empty, which we read as a growth problem. It was a validation error.
Forty characters
Razorpay rejects an order whose receipt field exceeds 40 characters. Both checkout paths built the receipt as order_<userId>_<timestamp>. An account id is a 36-character UUID, so that string was 56 characters and every order was refused. The customer saw a generic failure and the payment modal never opened.
// 5 + 8 + 6 + 1 + 12 = 32 characters, always
const stamp = Date.now().toString(36);
const nonce = crypto.randomBytes(3).toString("hex");
const digest = sha256(userId).slice(0, 12);
return `rcpt_${stamp}${nonce}_${digest}`;The user id is carried as a short digest rather than in full. The authoritative link from an order back to an account is a note set server-side at creation, which the verification route and the webhook both read — the receipt only needs to be unique and traceable.
Why the default currency mattered
The pricing page defaulted to USD, and USD routed to Stripe, which had never been configured on this deployment. A placeholder secret key hid the misconfiguration by looking valid to a startsWith check. So the default path to paying us was broken twice over. The default is now INR through Razorpay, and the interface reports which gateways are genuinely live rather than inferring it from the selected currency.