Fundyatra integration guide
This guide maps Rupay Hosted Checkout into Fundyatra campaign support pages and creator profile tips.
Recommended ownership model
Fundyatra owns the donation intent, campaign/creator relationship, supporter preferences, and accounting ledger. Rupay owns the checkout page, provider interaction, and confirmed payment state.
Fundyatra browser
-> Fundyatra backend creates a pending payment
-> Fundyatra backend creates a Rupay Checkout Session
-> browser redirects to Rupay
-> payment provider confirms payment to Rupay
-> Rupay signs a webhook to Fundyatra
-> Fundyatra atomically posts its ledger entry once
Never mark a Fundyatra contribution paid from the return-page query string.
Environment
Create Fundyatra from Rupay admin → Organizations. Rupay provisions two isolated environments in one transaction:
| Rupay environment | API key prefix | Use in Fundyatra |
|---|---|---|
| Staging | rp_test_ | Preview, QA, and staging deployments |
| Live | rp_live_ | The production fundyatra.com server |
Add the exact HTTPS origin for each deployment when creating the organization,
for example https://staging.fundyatra.com for staging and
https://fundyatra.com for live. Paths such as /payments/success do not
belong in the allowlist; Rupay validates the origin and then permits return-page
paths beneath it.
Copy each generated configuration immediately. API keys and environment webhook
signing secrets are displayed only once in the organization result. Keep
staging and live values in separate Fundyatra environment-variable sets.
Register a webhook endpoint separately with each environment's API key; Rupay
uses that environment's generated RUPAY_WEBHOOK_SECRET to sign its events.
RUPAY_API_BASE_URL=https://your-rupay-host.example
RUPAY_API_KEY=rp_test_replace_me
RUPAY_WEBHOOK_SECRET=whsec_replace_me
Keep these server-only. In Next.js, do not use the NEXT_PUBLIC_ prefix.
Suggested Fundyatra data
Add or retain a local payment record with at least:
| Field | Purpose |
|---|---|
id | Stable public Fundyatra payment ID |
campaignId or creatorId | Business owner of the contribution |
amountMinor, currency | Immutable expected payment value |
status | pending, paid, failed, or expired |
processor | rupay |
rupaySessionId | Unique nullable Checkout Session ID |
rupayEventId | Unique nullable event used to complete it |
paidAt | Provider-confirmed completion time |
Store supporter messages, anonymity choices, and other private campaign data only in Fundyatra. Send Rupay just the Fundyatra payment ID and non-sensitive routing metadata.
Create uniqueness constraints on rupaySessionId and processed webhook event
IDs. Ledger posting and the pending -> paid transition must occur in one
database transaction.
Create flow
The Fundyatra browser posts the target and amount to a Fundyatra endpoint such
as POST /api/payments/initiate. That endpoint must:
- authenticate or validate the supporter as applicable;
- load the campaign/creator from the database instead of trusting browser labels;
- validate the amount against Fundyatra's limits;
- create or reuse a local pending payment ID;
- call Rupay from the server with that ID as both
client_reference_idand part of the idempotency key; - store
session.idand returnsession.urlto the browser.
Example server helper:
type CreateRupaySessionInput = {
paymentId: string;
description: string;
amountMinor: number;
currency: "npr" | "usd";
email?: string;
phone?: string;
paymentKind: "campaign_contribution" | "creator_tip";
};
export async function createRupaySession(input: CreateRupaySessionInput) {
const response = await fetch(
`${requiredEnv("RUPAY_API_BASE_URL")}/v1/checkout/sessions`,
{
method: "POST",
headers: {
authorization: `Bearer ${requiredEnv("RUPAY_API_KEY")}`,
"content-type": "application/json",
"idempotency-key": `fundyatra-${input.paymentId}`,
},
body: JSON.stringify({
mode: "payment",
client_reference_id: input.paymentId,
currency: input.currency,
line_items: [
{
description: input.description,
quantity: 1,
amount_unit: input.amountMinor,
},
],
customer: {
email: input.email,
phone: input.phone,
},
success_url:
`${requiredEnv("FUNDYATRA_PUBLIC_URL")}/payments/success` +
"?session_id={CHECKOUT_SESSION_ID}",
cancel_url:
`${requiredEnv("FUNDYATRA_PUBLIC_URL")}/payments/cancelled` +
`?payment_id=${encodeURIComponent(input.paymentId)}`,
metadata: {
fundyatra_payment_id: input.paymentId,
payment_kind: input.paymentKind,
},
}),
},
);
const body = await response.json();
if (!response.ok) {
throw new Error(
body?.error?.message ?? `Rupay returned HTTP ${response.status}`,
);
}
return body as { id: string; url: string; expires_at: number };
}
function requiredEnv(name: string) {
const value = process.env[name];
if (!value) throw new Error(`${name} is not configured`);
return value.replace(/\/$/, "");
}
If the Fundyatra endpoint times out after calling Rupay, retry with the same local payment ID and request body. Do not create another local record solely because the response was lost. Rupay retains that operation identity across retryable failures and reuses any Stripe resources or local Checkout Session created before the response was interrupted.
Webhook flow
Register Fundyatra's public HTTPS endpoint once:
curl --request POST "$RUPAY_API_BASE_URL/v1/webhook_endpoints" \
--header "Authorization: Bearer $RUPAY_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"url": "https://web-production-be68e.up.railway.app/api/webhooks/rupay",
"enabled_events": ["checkout.session.completed"]
}'
The returned secret matches the environment's generated
RUPAY_WEBHOOK_SECRET. Use the same environment-specific value already saved
from organization provisioning.
A Next.js Route Handler should read request.text() before parsing JSON:
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("rupay-signature") ?? "";
if (!verifyRupaySignature(rawBody, signature)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const event = JSON.parse(rawBody) as RupayEvent;
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await database.transaction(async (tx) => {
if (await tx.webhookEvent.exists(event.id)) return;
const payment = await tx.payment.lockById(session.client_reference_id);
if (!payment) throw new Error("Unknown Fundyatra payment");
if (
session.id !== payment.rupaySessionId ||
session.payment_status !== "paid" ||
session.amount_total !== payment.amountMinor ||
session.currency !== payment.currency
) {
throw new Error("Rupay payment does not match Fundyatra payment");
}
if (payment.status !== "paid") {
await tx.payment.markPaid(payment.id, event.id);
await tx.ledger.postContributionOnce(payment.id);
}
await tx.webhookEvent.insert(event.id);
});
}
return NextResponse.json({ received: true });
}
function verifyRupaySignature(rawBody: string, header: string) {
const values = Object.fromEntries(
header.split(",").map((part) => part.trim().split("=", 2)),
);
const timestamp = Number(values.t);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const supplied = Buffer.from(values.v1 ?? "", "hex");
const expected = createHmac("sha256", requiredEnv("RUPAY_WEBHOOK_SECRET"))
.update(`${timestamp}.${rawBody}`)
.digest();
return (
supplied.length === expected.length && timingSafeEqual(supplied, expected)
);
}
Replace the example database calls with Fundyatra's actual transaction/locking
API. If processing is slow, atomically store the verified event and enqueue an
outbox job, then respond 2xx promptly.
See Webhooks for the complete event and retry contract.
Success and cancellation pages
The success page may call the Fundyatra backend, which can retrieve
GET /v1/checkout/sessions/{id} server-to-server to show current status. It
must never update the Fundyatra ledger. Webhooks remain authoritative.
The cancellation page can leave the local payment pending until the Rupay session expires, or mark the local attempt cancelled while allowing the user to start a new attempt with a new Fundyatra payment ID.
End-to-end acceptance checklist
Run each case in test mode and inspect both Rupay and Fundyatra records:
- campaign contribution succeeds by card;
- campaign contribution succeeds by eSewa;
- creator tip succeeds;
- user cancels and Fundyatra does not post money;
- invalid or expired webhook signature is rejected;
- the same webhook is delivered twice and posts one ledger entry;
- session creation is retried with the same idempotency key and returns one session;
- amount, currency, reference, or session mismatch is quarantined;
- Fundyatra returns
500, Rupay retries, and the later delivery succeeds; - an expired session cannot be paid;
- API key is absent from browser bundles, client logs, and analytics;
- production return URLs and webhook URLs use the final HTTPS domain.
Rupay's Nepal checkout fails closed when Cybersource recovery has not completed
its production cutover: the Cards option uses Stripe instead of Domestic Cards,
while eSewa remains available for NPR. Fundyatra does not need provider-specific
logic and should continue to trust only checkout.session.completed.
If Fundyatra specifically requires Domestic Cards, also test a Cybersource
payment where the buyer closes the Rupay page before browser completion. The
encrypted Cybersource webhook must still move the Rupay session to paid and
deliver exactly one completion event to Fundyatra. See the
Cybersource receiver configuration.
Do not switch to live mode until provider webhook verification and Rupay's merchant-webhook and payment-receipt background worker are configured in production.