3D Secure Flow

Full walkthrough of the card payment redirect and callback flow.

Why 3DS?

Many East African banks and card issuers require 3D Secure authentication for online transactions. Trying to process without 3DS results in declines from the issuing bank.

RohoPay uses a redirect-based 3DS flow:

  1. Send your customer to the authentication page
  2. The customer completes bank OTP / PIN
  3. The provider redirects back to your return_url
  4. A webhook confirms the authoritative final status

Step-by-Step Flow

Step 1: Initiate Payment

index.tsTypeScript
const { data } = await fetch("https://api.rohopay.com/api/v1/checkout", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    api_key: process.env.ROHOPAY_API_KEY,
    amount: 50000,
    currency: "UGX",
    customer_name: "John Doe",
    customer_email: "john@example.com",
    return_url: "https://your-app.com/checkout/complete",
    card_number: "4111111111111111",
    card_expiry: "10/26",
    card_cvv: "123",
  }),
}).then(r => r.json());

// Redirect user to the 3DS authentication page
window.location.href = data.payment_url;

Step 2: User Completes 3DS

The user is taken to the provider's secure page where they authenticate with their bank. Your app has no role in this step — do not try to embed it in an iframe.

Step 3: Handle the Return URL

After 3DS completes (success or failure), the provider redirects the user to your return_url with query parameters:

output.txtText
https://your-app.com/checkout/complete
  ?status=successful
  &reference=RHP-2024-CARD001
⚠️

The status query parameter is a provisional hint only — it reflects what the provider told the browser during the redirect. Do not fulfill orders based on this value alone. Always wait for the webhook or poll the transaction status for the definitive result.

Step 4: Poll or Wait for Webhook

After the user returns to your app, verify the true transaction status:

TypeScript (Polling on return page)TypeScript
// In your /checkout/complete route
export default async function CheckoutReturn({ searchParams }) {
  const ref = searchParams.reference;
  if (!ref) return <ErrorPage />;

  // Poll until confirmed
  let status = "pending";
  for (let i = 0; i < 12; i++) {
    await new Promise(r => setTimeout(r, 2500));
    const res = await fetch(`https://api.rohopay.com/api/v1/transactions/${ref}`, {
      headers: { Authorization: `Bearer ${process.env.ROHOPAY_API_KEY}` },
    });
    const { data } = await res.json();
    status = data.status;
    if (status !== "pending") break;
  }

  if (status === "successful") return <SuccessPage />;
  return <FailurePage />;
}
TypeScript (Webhook handler)TypeScript
// POST /api/webhooks/rohopay
import crypto from "crypto";

export async function POST(req: Request) {
  const sig = req.headers.get("x-rohopay-signature");
  const body = await req.text();

  // Verify HMAC signature
  const expected = crypto
    .createHmac("sha256", process.env.ROHOPAY_WEBHOOK_SECRET!)
    .update(body)
    .digest("hex");

  if (sig !== expected) return new Response("Invalid signature", { status: 401 });

  const event = JSON.parse(body);
  const { status, internal_reference } = event;

  if (status === "successful") {
    // Fulfill the order
    await fulfillOrder(internal_reference);
  }

  return new Response("OK", { status: 200 });
}

Expiry Validation

RohoPay validates card expiry on submission — expired cards return a 400 error before the payment is attempted:

response.jsonJSON
{
  "success": false,
  "error": {
    "code": "INVALID_CARD",
    "message": "card has expired"
  }
}

Frontend validation (React example):

component.tsxTSX
function isExpired(expiry: string): boolean {
  const [mm, yy] = expiry.replace(/\s/g, "").split("/");
  if (!mm || !yy) return false;
  const month = parseInt(mm, 10);
  const year = parseInt("20" + yy, 10);
  const now = new Date();
  return year < now.getFullYear() || (year === now.getFullYear() && month < now.getMonth() + 1);
}

Return URL Query Parameters

ParameterDescription
statusTransaction status — successful, failed, or pending (provisional only)
referenceRohoPay internal_reference

Common Issues

User closes the browser before completing 3DS

The transaction stays at pending. It will eventually expire on the provider side. No charge occurs.

Recommendation: Show the user an "In Progress" state with a "Resume payment" button that re-opens the same payment_url (or initiates a new one).

Bank shows X-Frame-Options error

Some banks block iframe embedding. This is why RohoPay uses a full browser redirect rather than an embedded iframe for the 3DS step. If you were using an iframe, switch to window.location.href = payment_url.

Redirect returns success but webhook says failed

Trust the webhook. Browser redirects can be manipulated or intercepted. The HMAC-verified webhook is the authoritative source of truth.