Checkout widget
Take payments on your own site — the checkout in a modal, one script tag.
Contents
What the widget does
One script tag on your site, and clicking Pay opens the Radok checkout in a modal over your own page. The customer transfers, your page is told, and they never leave.
It is the hosted checkout in an iframe, not a second implementation — so it gets the same bank details, the same confirmation, and the same fixes, without you upgrading anything.
<script src="https://cdn.jsdelivr.net/npm/@radokpay/js@0"></script>
<script>
const radok = Radok();
payButton.onclick = () =>
radok.checkout({
slug: "order-1042-9f4c1b",
onSuccess: () => (window.location = "/thank-you"),
});
</script>How it fits together
The amount is set by your server, never by the browser, so a customer cannot change what they are charged.
- Your server creates a payment link with your secret key and gets a
slug. - Your page passes that slug to the widget. The modal opens.
- The customer transfers to the account shown. Radok confirms it automatically.
- Radok POSTs
collection.successto your webhook endpoint. That is your signal to fulfil the order.
onSuccess is not proof of payment
collection.success webhook, server to server.Register your site first
The checkout refuses to be framed by a site you have not registered. Until you add yours, the modal opens and stays blank.
Add it under Developers → Embedding. Use the exact origin, scheme and port included — https://shop.example.com, http://localhost:3000. No path.
This is what stops another site framing your checkout and drawing its own form on top of it, so it is a list of your sites, not a formality.
Creating the link
One link per order, from your server. See Payment links for every field.
curl -X POST https://api.radokpay.com/v1/payment-links \
-H "Authorization: Bearer $RADOK_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Order #1042",
"amountType": "fixed",
"amount": { "value": 500000, "currency": "NGN" },
"linkType": "one_time"
}'value is in kobo — 500000 is ₦5,000.00. Use a one_time link for an order, so it closes after it is paid.
Installing
Two packages. Most sites need one of them.
Which one
| Field | Type | Description |
|---|---|---|
| @radokpay/js | any site | The widget itself. A script tag, or an import if you use a bundler. No dependencies, about 5 KB. |
| @radokpay/react | React | A component and a hook over @radokpay/js, which it installs for you. Use this instead of, not as well as, the package above. |
Any website — a script tag
<script src="https://cdn.jsdelivr.net/npm/@radokpay/js@0"></script>Nothing to build. It defines Radok() as a global, so the snippet at the top of this page works as-is.
Keep the @0
@0.1.1 — if you would rather review every change yourself.With a bundler or framework
npm install @radokpay/react
pnpm add @radokpay/react
yarn add @radokpay/react
bun add @radokpay/reactnpm install @radokpay/js
pnpm add @radokpay/js
yarn add @radokpay/js
bun add @radokpay/jsBoth ship ESM and CommonJS, and their own TypeScript types — there is no @types/ package to add.
Then
import { RadokCheckoutButton } from "@radokpay/react";
<RadokCheckoutButton
slug={slug}
className="btn-primary"
onSuccess={() => router.push("/thank-you")}
>
Pay ₦5,000
</RadokCheckoutButton>Or useRadokCheckout() for { open, close, isOpen } when you want your own button. Both are client components and work in the App Router — you can render them straight from a server component.
import { Radok } from "@radokpay/js";
Radok().checkout({ slug, onSuccess: () => {} });Requirements
Needs
| Field | Type | Description |
|---|---|---|
| Browser | any modern | Anything supporting ES2020 — Chrome, Safari, Firefox and Edge from 2020 onward, and their mobile versions. |
| React | 18 or newer | Only for @radokpay/react. It is a peer dependency, so it uses the copy you already have. |
| Node | 20 or newer | Only to install and build. Nothing here runs on your server at request time. |
| https | in production | The checkout's copy-to-clipboard buttons need a secure context. localhost counts; plain http on a real domain does not. |
A complete integration
Next.js App Router, three files, nothing left out. The same shape works anywhere — replace the route handler with whatever your server uses.
1. Your server creates the link
The amount comes from your own order record, never from the request body — otherwise a customer can name their own price. Prefilling customer means the signed-in payer is not asked for an email you already have.
import { getSession } from "@/lib/auth";
import { getOrder } from "@/lib/orders";
export async function POST(request) {
const session = await getSession();
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { orderId } = await request.json();
const order = await getOrder(orderId, session.userId);
if (!order) return Response.json({ error: "Not found" }, { status: 404 });
const res = await fetch("https://api.radokpay.com/v1/payment-links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RADOK_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Order #${order.id}`,
amountType: "fixed",
amount: { value: order.totalKobo, currency: "NGN" },
linkType: "one_time",
customer: { name: session.user.name, email: session.user.email },
successRedirectUrl: `https://shop.example.com/orders/${order.id}`,
}),
});
const { data, error } = await res.json();
if (error) return Response.json({ error: error.message }, { status: 502 });
// Store the slug against the order so the webhook can find it again.
await order.update({ radokSlug: data.slug });
return Response.json({ slug: data.slug });
}2. Your page opens the checkout
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useRadokCheckout } from "@radokpay/react";
export function PayButton({ orderId }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { open } = useRadokCheckout({
// The order is not paid yet — the webhook decides that. This only moves the UI on.
onSuccess: () => router.push(`/orders/${orderId}?pending=1`),
onError: (error) => alert(error.message),
});
async function startCheckout() {
setLoading(true);
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId }),
});
const { slug, error } = await res.json();
setLoading(false);
if (error) return alert(error);
open(slug);
}
return (
<button onClick={startCheckout} disabled={loading}>
{loading ? "Preparing…" : "Pay now"}
</button>
);
}3. Your webhook fulfils the order
This is the one that actually matters. Verify the signature, check the amount against your own record, and make it idempotent — Radok retries, so the same event will arrive more than once. See Webhooks for the signature scheme in full.
import crypto from "node:crypto";
import { getOrderBySlug, markPaid } from "@/lib/orders";
export async function POST(request) {
// The RAW body — JSON.parse and re-stringify will not reproduce the signed bytes.
const raw = await request.text();
const header = request.headers.get("x-radok-signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.RADOK_WEBHOOK_SECRET)
.update(`${parts.t}.${raw}`)
.digest("hex");
const valid = ["v1", "v1_prev"].some(
(field) =>
parts[field] &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts[field])),
);
if (!valid) return new Response("bad signature", { status: 400 });
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return new Response("stale", { status: 400 });
}
const event = JSON.parse(raw);
if (event.type !== "collection.success") return new Response("ok");
const order = await getOrderBySlug(event.data.slug);
if (!order) return new Response("ok"); // not ours, or already cleaned up
// Check the money against YOUR record, not the event alone.
if (event.data.amount.value !== order.totalKobo) {
console.error("amount mismatch", order.id, event.data.amount);
return new Response("ok");
}
// Idempotent: Radok retries, so this will run more than once for one payment.
await markPaid(order.id, { reference: event.data.reference });
return new Response("ok");
}Three things this code does on purpose
markPaid is idempotent, because a retry is normal — not an error.Options
radok.checkout(options)
| Field | Type | Description |
|---|---|---|
| slugrequired | string | From your server's payment-link response. |
| onSuccess | function | ({ reference }) => void. The payment settled. A UI signal — fulfil from the webhook. |
| onClose | function | The modal closed, for any reason, including after a success. Always called exactly once. |
| onError | function | ({ code, message }) => void. Terminal failure, or the 30-minute transfer window lapsed. |
checkout() returns { close() } so you can dismiss it yourself. Radok({ payOrigin }) points it at a different checkout host, which is what you want when developing locally.
Testing it
- Create the link with a test secret key (
sk_test_…). - Add your development origin —
http://localhost:3000— to the embedding allowlist. Plainhttpis accepted for localhost only. - Open the modal. It shows a TEST banner and a stub bank account, so there is no doubt which mode you are in.
- Press I’ve sent the transfer. Test links settle immediately — no real transfer needed — and your
onSuccessfires.
Running the checkout locally too? Pass Radok({ payOrigin: "http://localhost:3003" }).
If something is wrong
Symptoms
| Field | Type | Description |
|---|---|---|
| Modal opens, stays blank | allowlist | Your origin is not registered. Check Developers → Embedding matches exactly, including http vs https and the port. |
| Nothing happens on click | slug | The slug is missing or belongs to a disabled or already-paid one-time link. |
| onSuccess never fires | webhook | The payer closed the modal before the transfer landed. This is normal and is exactly why fulfilment belongs to the webhook. |
| Copy buttons do nothing | browser | The page is not on https. Clipboard access needs a secure context; localhost counts, plain http on a domain does not. |
Where to go next
- Payment links — every field you can set on the link the widget opens.
- Webhooks — read this before you go live. It is where fulfilment belongs.
- Testing — simulating payments beyond the checkout’s own test button.