RADOK

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.

The whole integration
<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.

  1. Your server creates a payment link with your secret key and gets a slug.
  2. Your page passes that slug to the widget. The modal opens.
  3. The customer transfers to the account shown. Radok confirms it automatically.
  4. Radok POSTs collection.success to your webhook endpoint. That is your signal to fulfil the order.

onSuccess is not proof of payment

It runs in the customer’s browser, so treat it as a UI signal — show a thank-you screen, move to the next step. Anything that ships goods, grants access or credits an account belongs behind the 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
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

FieldTypeDescription
@radokpay/jsany siteThe widget itself. A script tag, or an import if you use a bundler. No dependencies, about 5 KB.
@radokpay/reactReactA 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

html
<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

It pins the major version, so you get fixes but never a breaking change you did not ask for. It matters more than it looks: the CDN tells browsers to hold this file for seven days, so an unpinned URL both delays a fix reaching your customers and would carry them across a future 1.0 with no warning. Pin exactly — @0.1.1 — if you would rather review every change yourself.

With a bundler or framework

React — npm, pnpm, yarn, bun
npm  install @radokpay/react
pnpm add     @radokpay/react
yarn add     @radokpay/react
bun  add     @radokpay/react
Everything else
npm  install @radokpay/js
pnpm add     @radokpay/js
yarn add     @radokpay/js
bun  add     @radokpay/js

Both ship ESM and CommonJS, and their own TypeScript types — there is no @types/ package to add.

Then

React
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.

Anything else
import { Radok } from "@radokpay/js";

Radok().checkout({ slug, onSuccess: () => {} });

Requirements

Needs

FieldTypeDescription
Browserany modernAnything supporting ES2020 — Chrome, Safari, Firefox and Edge from 2020 onward, and their mobile versions.
React18 or newerOnly for @radokpay/react. It is a peer dependency, so it uses the copy you already have.
Node20 or newerOnly to install and build. Nothing here runs on your server at request time.
httpsin productionThe 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.

app/api/checkout/route.ts
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

app/checkout/PayButton.tsx
"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.

app/api/webhooks/radok/route.ts
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

It reads the raw body before parsing, because re-serialising JSON changes the bytes the signature covers. It compares the amount against its own order rather than trusting the event alone. And markPaid is idempotent, because a retry is normal — not an error.

Options

radok.checkout(options)

FieldTypeDescription
slugrequiredstringFrom your server's payment-link response.
onSuccessfunction({ reference }) => void. The payment settled. A UI signal — fulfil from the webhook.
onClosefunctionThe modal closed, for any reason, including after a success. Always called exactly once.
onErrorfunction({ 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

  1. Create the link with a test secret key (sk_test_…).
  2. Add your development origin — http://localhost:3000 — to the embedding allowlist. Plain http is accepted for localhost only.
  3. Open the modal. It shows a TEST banner and a stub bank account, so there is no doubt which mode you are in.
  4. Press I’ve sent the transfer. Test links settle immediately — no real transfer needed — and your onSuccess fires.

Running the checkout locally too? Pass Radok({ payOrigin: "http://localhost:3003" }).

If something is wrong

Symptoms

FieldTypeDescription
Modal opens, stays blankallowlistYour origin is not registered. Check Developers → Embedding matches exactly, including http vs https and the port.
Nothing happens on clickslugThe slug is missing or belongs to a disabled or already-paid one-time link.
onSuccess never fireswebhookThe payer closed the modal before the transfer landed. This is normal and is exactly why fulfilment belongs to the webhook.
Copy buttons do nothingbrowserThe 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.