RupayDeveloper docsOpenAPI specification

Fundyatra integration guide

This guide maps Rupay Hosted Checkout into Fundyatra campaign support pages and creator profile tips.

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 environmentAPI key prefixUse in Fundyatra
Stagingrp_test_Preview, QA, and staging deployments
Liverp_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:

FieldPurpose
idStable public Fundyatra payment ID
campaignId or creatorIdBusiness owner of the contribution
amountMinor, currencyImmutable expected payment value
statuspending, paid, failed, or expired
processorrupay
rupaySessionIdUnique nullable Checkout Session ID
rupayEventIdUnique nullable event used to complete it
paidAtProvider-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:

  1. authenticate or validate the supporter as applicable;
  2. load the campaign/creator from the database instead of trusting browser labels;
  3. validate the amount against Fundyatra's limits;
  4. create or reuse a local pending payment ID;
  5. call Rupay from the server with that ID as both client_reference_id and part of the idempotency key;
  6. store session.id and return session.url to 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.