3D Secure Flow

The redirect flow for the rare case where the issuing bank mandates full 3D Secure.

When does this apply?

Most cards complete via a one-time code (OTP) sent to the cardholder's phone or email — no redirect required. See Card Payments Overview for that flow, which is the common case.

This page covers the exception: when next_action.type is redirect in the checkout response. That happens when the issuing bank mandates full 3D Secure authentication before it will authorize the transaction. RohoPay cannot skip this when a bank requires it — attempting to bypass it results in a decline.

Redirect-Based 3DS Flow

  1. Send your customer to next_action.redirect_url
  2. The customer completes bank OTP / PIN on the bank's own page
  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: "5531886652142950",
    card_expiry: "09/32",
    card_cvv: "564",
  }),
}).then(r => r.json());

if (data.next_action.type === "redirect") {
  // Present as an in-app overlay/iframe where possible;
  // a full page redirect is an acceptable fallback.
  window.location.href = data.next_action.redirect_url;
}

Step 2: User Completes 3DS

The user is taken to the bank's secure page where they authenticate. Prefer an in-app overlay/iframe over a full page navigation when your integration supports it, so the user doesn't fully leave your app.

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 redirect_url (or initiates a new checkout).

Bank shows X-Frame-Options error

Some banks block iframe embedding for their 3DS page. If that happens, fall back to a full browser redirect (window.location.href = next_action.redirect_url) rather than an embedded iframe.

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.

I expected a redirect but got an OTP prompt instead

That's the common case, not a bug — see Card Payments Overview. Only branch your UI on next_action.type; don't assume redirect is the default path.