Nepse Edge: Rupay through the Stripe SDK
Rupay owns catalog pricing, payment methods, coupons, and paid access periods. Nepse Edge owns user accounts and grants Premium from a verified paid period. This integration uses manual renewal, with one-time purchases of 3 months, 6 months, or 1 year. It does not use Stripe automatic subscriptions.
1. Prepare Rupay
Deploy the code and apply database migration 0032_charming_steel_serpent.sql
before using these endpoints. It adds immutable Price snapshots, customer metadata,
and webhook API versions. Catalog offers are backfilled and subsequent catalog
edits maintain their snapshots. Neither deployment nor a production migration
has been performed merely by editing this repository.
In the Rupay catalog, create the Premium offers with subscription durations
3_months, 6_months, and year and NPR prices. Simple products or variants are
supported. Register the Nepse Edge HTTPS return origins for the merchant.
Provision a server API key with customers:write, customers:read,
checkout_sessions:write, and checkout_sessions:read. Use a setup key with
webhook_endpoints:write to register the notification endpoint.
2. Configure the SDK
Use stripe@20.4.1 and pin the API version:
import Stripe from "stripe";
const origin = new URL(process.env.RUPAY_API_URL!);
const rupay = new Stripe(process.env.RUPAY_API_KEY!, {
host: origin.hostname,
protocol: "https",
port: Number(origin.port || 443),
apiVersion: "2026-02-25.clover",
httpClient: Stripe.createFetchHttpClient(),
});
The URL is the deployed Rupay origin, without /v1. Keys stay server-side.
Stripe-owned customer/price IDs do not work against Rupay.
3. Map the plan buttons
Discover offers through rupay.products.list() and
rupay.prices.list({ product: productId, active: true }). Price metadata includes
rupay_variant_id when applicable and rupay_period_interval for fixed periods.
These Prices have type: "one_time", because they do not authorize recurring
collection. Store the three selected Price IDs in server configuration:
| Nepse Edge button | Setting | Expected period |
|---|---|---|
3m | RUPAY_PREMIUM_3M_PRICE_ID | 3_months |
6m | RUPAY_PREMIUM_6M_PRICE_ID | 6_months |
1y | RUPAY_PREMIUM_1Y_PRICE_ID | year |
Also configure RUPAY_API_URL, RUPAY_API_KEY, RUPAY_WEBHOOK_SECRET,
RUPAY_SUCCESS_URL, and RUPAY_CANCEL_URL. Update the plan mapping after a catalog
edit replaces a Price. Pending purchases retain their original snapshot.
4. Associate the signed-in user
Look up the saved Rupay customer ID for the authenticated user. If absent, create and persist it using a stable per-user idempotency key. Retry with the identical original body; do not recreate a customer on every purchase.
const customer = await rupay.customers.create(
{ email: user.email, name: user.name, metadata: { nepse_user_id: user.id } },
{ idempotencyKey: `nepse-customer-${user.id}` },
);
// Persist customer.id against user.id before creating checkout.
Customer IDs and Price IDs must come from server-owned mappings, never unchecked browser input. Customer metadata is an association aid; the local persisted mapping remains necessary for authorization.
5. Create checkout on the server
Create a local pending order first, recording the user, customer, selected Price ID, and environment. Use its stable ID for checkout idempotency and correlation:
const session = await rupay.checkout.sessions.create(
{
mode: "payment",
customer: customerId,
client_reference_id: order.id,
line_items: [{ price: configuredPriceId, quantity: 1 }],
allow_promotion_codes: true,
success_url: process.env.RUPAY_SUCCESS_URL!,
cancel_url: process.env.RUPAY_CANCEL_URL!,
},
{ idempotencyKey: `nepse-checkout-${order.id}` },
);
// Redirect the browser to session.url; a return redirect does not grant Premium.
Payment methods and coupons are selected inside Rupay. Coupon replacement keeps the customer, merchant order reference, and Price snapshot, but can produce a new Checkout Session ID. Match fulfillment by the saved order reference, customer, and Price, not solely the original session ID.
6. Register and verify completion events
Register once, using the setup key, and store the returned secret:
const endpoint = await rupay.webhookEndpoints.create(
{
url: "https://YOUR_NEPSE_EDGE_HOST/api/subscription/rupay/webhook",
enabled_events: ["checkout.session.completed"],
},
{ idempotencyKey: "nepse-premium-webhook-v1" },
);
// Store endpoint.secret as RUPAY_WEBHOOK_SECRET.
The endpoint version follows the pinned SDK API version. Verify the exact raw body with the Stripe SDK. Cloudflare/Bun should use the async verifier:
const rawBody = await request.text();
const event = await rupay.webhooks.constructEventAsync(
rawBody,
request.headers.get("stripe-signature") ?? "",
process.env.RUPAY_WEBHOOK_SECRET!,
);
if (event.type === "checkout.session.completed") {
const notified = event.data.object as Stripe.Checkout.Session;
const session = await rupay.checkout.sessions.retrieve(notified.id);
const periodStart = Number(session.metadata?.rupay_period_start);
const periodEnd = Number(session.metadata?.rupay_period_end);
// Validate and persist using the rules below before acknowledging the event.
}
Require all of the following before granting access:
- Valid signature and expected live/test mode on both event and retrieved session.
session.payment_status === "paid",session.status === "complete", andsession.metadata.rupay_renewal_mode === "manual".client_reference_idresolves to an existing local order;customerandmetadata.rupay_price_idequal the values saved with that order.- Period values are finite, positive safe integers; the end is after the start and still in the future. Pending checkouts do not expose a verified paid expiry.
In one database transaction, deduplicate event.id, record the paid order, and
sync Premium to the verified period. Also deduplicate fulfillment by order so
multiple valid event IDs cannot grant duplicate access. Acknowledge only after
commit; return a non-2xx response on transient database/API failures. A late event
for an older order must not shorten a newer entitlement. Never add months locally.
Enforce expiry on every entitlement read, and reconcile unresolved orders using SDK session retrieval. This SDK subset sends completion notifications; it does not promise Stripe subscription expiry/cancellation events. Early purchases currently overlap periods rather than extending the previous expiry.
Nepse Edge migration still required
Replace its direct eSewa subscription form with the server SDK call, save the customer/order mapping, remove local payment-method and coupon selection, and install the verified webhook handler. Keep processing callbacks for existing pending eSewa orders and preserve existing paid entitlements. No Nepse Edge source files are changed by this Rupay implementation.
For exact boundaries, see Stripe SDK compatibility.