Billing
Mintoken paid plans are billed via Razorpay (INR primary). This page documents the client-side flow for integrating the Razorpay Checkout widget into your own dashboard. Backend endpoints + plan limits live on the Plans page.
The 3-step flow
- Create an order (server-side):
POST /v1/billing/razorpay/orderwith the plan slug. Returnsorder_id,amountin paise, and the publickey_idsafe to send to the browser. - Open Razorpay Checkout (client-side): pass the order_id and key_id to the Razorpay SDK, which renders the payment modal. User pays with UPI / card / netbanking / wallet.
- Verify the signature (server-side):
POST /v1/billing/razorpay/verifywith the payment_id + signature returned by Checkout. Backend recomputes the HMAC and updatesprofiles.planin Supabase. New quota live on the very next proxy request.
handler when the payment succeeds, but a malicious user can call that callback by hand without paying. The razorpay_signature is the proof; verify it on your server before flipping any feature flag.Step 1: create the order
POST /v1/billing/razorpay/order
Authentication: Supabase JWT (the access_token from POST /v1/auth/login), not a mintoken key.
Body fields:
| Field | Type | Notes |
|---|---|---|
plan | string | "pro" or "team". Free / enterprise are not orderable through this endpoint. |
curl -X POST https://api.mintoken.in/v1/billing/razorpay/order \
-H "Authorization: Bearer <supabase-jwt>" \
-H "Content-Type: application/json" \
-d '{"plan": "pro"}'
# Response:
# {
# "order_id": "order_NXXX...",
# "amount": 159900,
# "currency": "INR",
# "key_id": "rzp_live_XXX",
# "plan": "pro"
# }
Errors
| Status | Meaning |
|---|---|
400 | Unsupported plan slug. |
503 | Razorpay credentials missing on the server. This is a config issue, not a payment issue. |
502 | Razorpay upstream rejected the order creation. |
Step 2: open Razorpay Checkout (client-side)
Razorpay Checkout is a widget loaded from checkout.razorpay.com/v1/checkout.js. Load it once on the page; instantiate per-upgrade.
// 1. Load the Razorpay Checkout script (once, at the top of your page)
// <script src="https://checkout.razorpay.com/v1/checkout.js"></script>
// 2. Get a fresh order from your backend (which calls /v1/billing/razorpay/order)
const order = await createOrder("pro");
// 3. Open Razorpay Checkout
const rzp = new window.Razorpay({
key: order.key_id,
amount: order.amount,
currency: order.currency,
name: "Mintoken",
description: "Pro plan upgrade",
order_id: order.order_id,
prefill: {
email: currentUser.email,
},
theme: { color: "#FF6B1A" },
handler: async (response) => {
// 4. Verify the signature server-side
const verify = await fetch("/api/verify-upgrade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan: "pro",
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
}),
});
if (verify.ok) {
// Plan is now upgraded. Refresh the user object.
window.location.reload();
}
},
modal: {
ondismiss: () => {
// User closed checkout without paying. No upgrade.
},
},
});
rzp.open();
Theme color #FF6B1A is the mintoken orange and is what the hosted dashboard uses. Replace with your brand color if you white-label.
Step 3: verify the signature (server-side)
POST /v1/billing/razorpay/verify
Body fields (all required):
| Field | Source |
|---|---|
plan | The plan you originally created the order for. Must match. |
razorpay_order_id | From the Step 1 response. |
razorpay_payment_id | From the Razorpay Checkout success callback. |
razorpay_signature | HMAC-SHA256 of order_id|payment_id signed with your Razorpay secret. Razorpay Checkout returns this; mintoken backend re-computes and compares. |
curl -X POST https://api.mintoken.in/v1/billing/razorpay/verify \
-H "Authorization: Bearer <supabase-jwt>" \
-H "Content-Type: application/json" \
-d '{
"plan": "pro",
"razorpay_order_id": "order_NXXX...",
"razorpay_payment_id": "pay_NYYY...",
"razorpay_signature": "<hex-signature>"
}'
# Response on success:
# {
# "message": "Plan upgraded",
# "old_plan": "free",
# "new_plan": "pro",
# "payment_id": "pay_NYYY..."
# }
# Response on bad signature:
# 400 { "detail": "Invalid payment signature" }
What happens on success
- Signature is recomputed and matched.
profiles.planis updated to the new plan in Supabase.- The new quota / RPM limits apply on the very next proxy request.
- The upgrade is logged with
old_plan -> new_planand the payment id for the audit trail.
Server-side webhook (recommended)
The verify endpoint is sufficient for the happy path, but Razorpay also fires async webhooks at /v1/billing/razorpay/webhook for events like payment.captured, payment.failed, refund.created. The webhook is the source of truth for renewal billing once subscriptions ship; verify handles only the upgrade moment.