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
- Send your customer to
next_action.redirect_url - The customer completes bank OTP / PIN on the bank's own page
- The provider redirects back to your
return_url - 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: "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:
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:
// 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 />;
}
// 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:
{
"success": false,
"error": {
"code": "INVALID_CARD",
"message": "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
| Parameter | Description |
|---|---|
status | Transaction status — successful, failed, or pending (provisional only) |
reference | RohoPay 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.
