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

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:
https://your-app.com/checkout/complete
  ?status=success
  &reference=RHP-2024-CARD001
  &order_ref=relworx-internal-ref
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:
// 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 />;
}

Expiry Validation

RohoPay validates card expiry on submission — expired cards return a 400 error before the payment is attempted:
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "card_expiry: card has expired"
  }
}
Frontend validation (React example):
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
statussuccess or failed (provisional only)
referenceRohoPay internal_reference
order_refProvider internal reference

Common Issues

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).
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.
Trust the webhook. Browser redirects can be manipulated or intercepted. The HMAC-verified webhook is the authoritative source of truth.