Herman Stander
Core team developer and marketing
2025-05-08
Ever done a payment gateway integration? There’s a lot of back and forth between server and client. You could almost call it a back-and-forth-end!
In this very basic guide, I’ll walk through how you might do it using RedwoodSDK and Paystack.
Integrating with a payment gateway the traditional way usually involves:
This setup creates a disjointed workflow, requiring extra boilerplate just to bridge the frontend and backend.
With RedwoodSDK, a full-stack framework, this complexity is significantly reduced:
Everything is handled in one unified project. You get the same separation of concerns, but without the logistical pain of separate deployments and services.
Here’s a minimal example of what this might look like in a RedwoodSDK setup.
First, we create a React server component:
// payment.ts
"use server";
import { db } from "@/db";
import { AppContext } from "@/worker";
import { env } from "cloudflare:workers";
const INITIATE_PAYMENT_LINK = "https://api.paystack.co/transaction/initialize";
const VERIFY_PAYMENT_LINK = "https://api.paystack.co/transaction/verify/";
const CALLBACK_URL = "http://localhost:5173/subscribe/callback"; // Testing locally
export async function initiatePayment(email: string, plan: string) {
const response = await fetch(INITIATE_PAYMENT_LINK, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.PAYSTACK_SECRET_KEY}`,
},
body: JSON.stringify({ email, plan, callback_url: CALLBACK_URL }),
});
const data = await response.json();
return data;
}
export async function verifyPayment(reference: string) {
const response = await fetch(`${VERIFY_PAYMENT_LINK}${reference}`, {
headers: {
Authorization: `Bearer ${env.PAYSTACK_SECRET_KEY}`,
},
});
const data = await response.json();
return data;
}
Note how we do DB queries, access ENV vars, and call secure APIs — all within RedwoodSDK.
Now let’s see what the client is doing:
"use client";
import { initiatePayment } from "@/app/actions/payment";
import { packages } from "@/app/Constants";
import { useState } from "react";
export default function Subscribe() {
const [selectedPackage, setSelectedPackage] = useState("Starter");
const [email, setEmail] = useState("");
const pkg = packages.find((pkg) => pkg.title === selectedPackage);
const handleSelectPackage = (packageName: string) => {
setSelectedPackage(packageName);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!pkg) return;
const response = await initiatePayment(email, pkg.planCode);
if (response.status) {
window.location.href = response.data.authorization_url;
}
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit">Subscribe to {selectedPackage} Package</button>
</form>
);
}
Let’s register the callback URL in our app:
// worker.tsx
export default defineApp([
setCommonHeaders(),
async ({ ctx }) => {
await setupDb(env);
setupSessionStore(env);
if (ctx.session?.userId) {
ctx.user = await db.user.findUnique({
where: { id: ctx.session.userId },
});
}
},
render(Document, [
route("/", [HomePage]),
prefix("/subscribe", subscribeRoutes),
]),
]);
And now the route definitions:
// subscribeRoutes.ts
import { route, index } from "rwsdk/router";
import { verifyPayment } from "@/app/actions/payment";
import Subscribe from "./Subscribe";
import PaymentSuccess from "./PaymentSuccess";
import PaymentError from "./PaymentError";
const subscribeRoutes = [
index(Subscribe),
route("/callback", async (ctx, request) => {
const url = new URL(request.url);
const reference = url.searchParams.get("reference") || "";
const payment = await verifyPayment(reference);
if (payment.data.status === "success") {
const user = await db.user.findUnique({
where: { id: ctx.user?.id },
});
if (user) {
const subscription = await db.subscription.findUnique({
where: { userId: user.id },
});
// Additional logic...
}
return Response.redirect(
new URL("/subscribe/payment-success", request.url)
);
}
return Response.redirect(new URL("/subscribe/payment-failed", request.url));
}),
route("/payment-success", PaymentSuccess),
route("/payment-failed", PaymentError),
];
export default subscribeRoutes;
Note how were are simply sending a valid Response, be it React or a Redirect. This could be done in many ways, but it shows how we think about Request/Response, how you could interrupt and return something else instead based on the outcome of a normal function, DB call and so on.
You get the full power of the request/response cycle with RedwoodSDK's routing, while keeping everything in one cohesive codebase.
Payment integration doesn’t have to be a mess of microservices. RedwoodSDK helps you build secure, full-stack features like this with minimal boilerplate.
Happy building!