# The PayFast Universal Hub: One Webhook, Six Applications, Zero Leakage
PayFast's merchant dashboard allows one set of return, cancel, and notify URLs per account. One notify URL. One merchant account.
I have six applications that process payments through a shared PayFast merchant account:
- AdminOS (subscription tiers)
- VarsityOS (premium features)
- StokvelOS (membership)
- K53 Drill Master (premium game modes)
- WatchSankofa (creator payouts, subscriptions)
- Sanyu Botanicals (product sales)
The solution: creativelynanda.co.za is the universal PayFast hub.
The Architecture
Every PayFast payment across all six applications is initiated with a structured m_payment_id:
m_payment_id format: {app}_{userId36}_{tier}_{timestamp}
Examples:
adminos_abc123_growth_1717891200
k53_def456_premium_1717891300
varsityos_ghi789_starter_1717891400
The prefix before the first underscore identifies the application. The universal ITN handler at /api/payfast/universal-notify reads this prefix and routes the payment confirmation to the correct application's Supabase instance.
The Handler
export async function POST(req: NextRequest) {
const body = await req.formData();
const mPaymentId = body.get('m_payment_id') as string;
const app = mPaymentId.split('_')[0]; // 'adminos', 'k53', etc.
const config = getAppConfig(app);
if (!config) return new Response('Unknown app', { status: 400 });
// Verify signature with app-specific passphrase
const isValid = await verifySignature(body, config.passphrase);
if (!isValid) return new Response('Invalid signature', { status: 403 });
// Route to app-specific handler
return config.handler(body, config.supabase);
}
Each application has its own Supabase instance, its own passphrase, and its own handler function. The routing layer is the only place all six coexist.
Signature Verification: The Bug That Taught Me Most
PayFast ITN verification requires regenerating the signature from the POST body and comparing it to the signature field. Simple in principle; subtle in practice.
The bug I encountered in production: my original implementation sorted the POST parameters alphabetically before generating the signature. PayFast verifies using insertion order, the order the payment form submitted the fields.
The fix was removing the .sort() call. Three characters removed. One hour of debugging that taught me more about the difference between "the API docs say" and "the API actually does" than any tutorial has.
The lesson: always verify your signature implementation against PayFast's PHP reference implementation, not against your intuition about how alphabetical sorting should work.
The Return and Cancel Pages
PayFast's dashboard URL configuration is the global fallback. Each application overrides it per-payment using PayFast's per-payment URL fields:
return_url: https://creativelynanda.co.za/payfast/return?app=adminos
cancel_url: https://creativelynanda.co.za/payfast/cancel?app=adminos
notify_url: https://creativelynanda.co.za/api/payfast/universal-notify
The return and cancel pages read the ?app= parameter and render the appropriate branded experience: AdminOS branding for AdminOS payments, K53 branding for K53 payments.
Without the ?app= parameter (the PayFast dashboard fallback), both pages render a generic Mirembe Muse branded experience.
What This Architecture Makes Possible
Adding a new application to the payment hub requires four steps:
- Add a
case 'newapp':block ingetAppConfig() - Add the app to
APP_CONFIGSin the return and cancel pages - Add three Vercel environment variables
- Set
m_payment_idcorrectly in the new app's initiate route
A new revenue stream in an afternoon.
What infrastructure would let you add a new product in an afternoon?
Reader Insights
0 responses
No insights yet. Be the first!