PayFast Signature Mismatch in Node.js — What the Docs Don't Tell You
March 25, 2026If you've spent hours staring at "Generated signature does not match submitted signature" from PayFast, you're not alone. I just went through this building a subscription payment flow for HigherFrequency, and the answer isn't what you'd expect.
PayFast's developer docs provide a Node.js signature example. It doesn't work. Here's what actually happened, why, and how I fixed it.
TL;DR: JavaScript's
encodeURIComponenturlencodeThe PayFast Signature Mismatch Error
Simple enough. Express app, form POST to PayFast, R49/month subscription. I followed their custom integration docs to the letter.
The payment data looks like this:
const paymentData = { merchant_id: 'YOUR_ID', merchant_key: 'YOUR_KEY', return_url: 'https://yoursite.com/return?email=user@example.com', cancel_url: 'https://yoursite.com/?payment=cancelled', notify_url: 'https://yoursite.com/payfast/notify', email_address: 'user@example.com', amount: '49.00', item_name: 'My Product - Monthly', subscription_type: '1', recurring_amount: '49.00', frequency: '3', cycles: '0', };
Generate an MD5 signature, pass it as a hidden form field, submit to PayFast. Straightforward.
What PayFast's Node.js Docs Say
Their official example:
function generateSignature(data, passphrase) { let pfOutput = ''; Object.keys(data).forEach(key => { if (data[key] !== '') { pfOutput += `${key}=${encodeURIComponent(data[key])}&`; } }); pfOutput = pfOutput.slice(0, -1); if (passphrase) { pfOutput += `&passphrase=${encodeURIComponent(passphrase)}`; } return crypto.createHash('md5').update(pfOutput).digest('hex'); }
Looks clean. Copy, paste, ship it. Right?
400 Bad Request. Generated signature does not match submitted signature.
The Debugging Spiral
I tried everything:
- Alphabetical key order (their older docs mention this) — still fails
- Submission order (their newer docs say this) — still fails
- With passphrase, without passphrase — both fail
- Sandbox credentials, live credentials — both fail
- Stripped the item name down to pure ASCII — still fails
- Removed every optional field — still fails
I even compared the exact string byte-by-byte against what I'd expect. The MD5 hash was correct for the string I was generating. So the string itself had to be wrong.
The Real Problem: encodeURIComponent vs urlencode
PayFast runs PHP on their backend. When they receive your form POST, they decode the values, then run PHP's
urlencode()Here's the thing. JavaScript's
encodeURIComponenturlencodeThe big one: spaces.
| Character | JS
encodeURIComponenturlencode%20+~~%7E!!%21''%27((%28))%29**%2ASo if your
item_name- Your code:
item_name=My%20Product%20-%20Monthly - PayFast's server:
item_name=My+Product+-+Monthly
Different strings. Different MD5. Signature mismatch.
The PayFast Node.js docs blindly use
encodeURIComponentProductA"Just Add .replace(/%20/g, '+')?"
That's what I tried next:
pfOutput += `${key}=${encodeURIComponent(data[key]).replace(/%20/g, '+')}&`;
Still failed.
Replacing
%20++And there's a more subtle issue. When your JavaScript creates a hidden form and submits it, the browser encodes the values using
application/x-www-form-urlencodedFix 1: Pure Node.js (Replicate PHP's urlencode)
You can bridge the gap between
encodeURIComponenturlencodefunction phpUrlEncode(str) { return encodeURIComponent(str) .replace(/!/g, '%21') .replace(/'/g, '%27') .replace(/\(/g, '%28') .replace(/\)/g, '%29') .replace(/\*/g, '%2A') .replace(/~/g, '%7E') .replace(/%20/g, '+'); } function generateSignature(data, passPhrase) { let pfOutput = ''; Object.keys(data).forEach(key => { if (data[key] !== '') { pfOutput += `${key}=${phpUrlEncode(String(data[key]).trim())}&`; } }); pfOutput = pfOutput.slice(0, -1); if (passPhrase) { pfOutput += `&passphrase=${phpUrlEncode(passPhrase.trim())}`; } return crypto.createHash('md5').update(pfOutput).digest('hex'); }
This should work for most cases. I say "should" because I can't guarantee there isn't some other edge case where JS and PHP encoding diverge. If you're paranoid (and after this experience, you should be), there's a more bulletproof option.
Fix 2: Use PHP
When you've had enough and just want it to work. The only 100% reliable way I found to generate a PayFast-compatible signature from a Node.js app is to have PHP do it.
Create a small PHP script (
gensig.php<?php $data = json_decode($argv[1] ?? '{}', true); $passphrase = $argv[2] ?? ''; $pfOutput = ''; foreach ($data as $key => $val) { if ($val !== '') { $pfOutput .= $key . '=' . urlencode(trim($val)) . '&'; } } $pfOutput = substr($pfOutput, 0, -1); if ($passphrase) { $pfOutput .= '&passphrase=' . urlencode(trim($passphrase)); } echo md5($pfOutput);
Call it from Node:
const { execFileSync } = require('child_process'); function generateSignature(data, passPhrase) { const result = execFileSync('php', [ path.join(__dirname, 'gensig.php'), JSON.stringify(data), passPhrase || '' ], { encoding: 'utf8', timeout: 5000 }); return result.trim(); }
This works because PHP's
urlencodeYes, your Node.js app now shells out to PHP for one function call. I don't love it either. But it works on the first try, every time, with any passphrase, with any data. The performance hit is negligible — it's one
execOther Things the Docs Don't Mention
A few more things I discovered the hard way:
Field order matters. The signature string must use fields in the same order they appear in your form. Not alphabetical. Their older docs say alphabetical. Their newer docs say submission order. The newer docs are correct, but both versions are still live on different pages.
Don't pre-encode URLs in your data. If your
return_url?email=user%40example.comurlencode%40%2540%40%2540Use raw values:
// Wrong return_url: baseUrl + '/return?email=' + encodeURIComponent(email), // Right return_url: baseUrl + '/return?email=' + email,
Sandbox credentials are separate from live credentials. This sounds obvious but it's not stated clearly. Your live merchant ID will not work on
sandbox.payfast.co.zaWatch out for 0
'0'cycles: '0'if (value)'0'parseIntYour notify_url must be publicly reachable. If you're developing on localhost, PayFast's ITN will never reach you. Use ngrok or deploy to a staging server for testing. The payment will appear to work (user gets redirected back) but the ITN confirmation will silently fail.
Settings take time to propagate. When you toggle the signature requirement or change your passphrase on the PayFast dashboard, it doesn't take effect immediately. I spent 20 minutes debugging a "signature mismatch" that was actually PayFast's servers still using the old passphrase.
The Working Subscription Setup
For anyone building a PayFast subscription in Node.js, here are the fields you need:
const paymentData = { merchant_id: process.env.PAYFAST_MERCHANT_ID, merchant_key: process.env.PAYFAST_MERCHANT_KEY, return_url: baseUrl + '/payfast/return?email=' + email, cancel_url: baseUrl + '/?payment=cancelled', notify_url: baseUrl + '/payfast/notify', email_address: email, amount: '49.00', item_name: 'Your Product - Monthly', item_description: 'Description here', custom_str1: email, subscription_type: '1', // 1 = subscription recurring_amount: '49.00', // amount per cycle frequency: '3', // 3 = monthly cycles: '0', // 0 = indefinite }; // Generate signature via PHP paymentData.signature = generateSignature(paymentData, process.env.PAYFAST_PASSPHRASE);
Submit this as a hidden form POST to
https://www.payfast.co.za/eng/processsandbox.payfast.co.zaDon't Forget the ITN Handler
PayFast confirms payments via ITN (Instant Transaction Notification) — a POST to your
notify_urlapp.post('/payfast/notify', async (req, res) => { const pfData = req.body; if (pfData.payment_status === 'COMPLETE') { // Grant access, save to database, etc. } // Always respond 200 res.sendStatus(200); });
Make sure your nginx config doesn't block the ITN POST with bot detection or basic auth rules. PayFast's servers will hit this endpoint, and if they get a 403, your payment won't be confirmed.
Good Luck Getting Help
When the docs failed me, I tried PayFast's support. Here's how that went:
I spent 30 minutes navigating their site trying to find a way to talk to a human. Their support bot is a menu that links to help articles — the same screenshots-of-the-dashboard articles that every developer has already seen before they resort to contacting support. "How to set your passphrase" with a screenshot of the settings page. Thanks, that wasn't the first place I looked.
After 10 minutes in the bot loop, I gave up. There's no obvious way to reach an actual person who can look at your specific signature string and tell you what's different on their end. A signature debugging tool — paste your string, see what we computed, spot the difference — would save thousands of developer hours. Instead, you get a 400 error and a one-line message.
For a payment gateway that's the default choice for South African businesses, the developer experience needs serious work.
The Takeaway
PayFast's Node.js documentation has a fundamental bug: it uses
encodeURIComponenturlencode- Shell out to PHP for signature generation (what I did)
- Write a custom JS function that exactly replicates PHP behaviour for every edge case
urlencode - Disable the signature requirement on your PayFast dashboard (not recommended for production)
If you're a Python dev, you'll hit the same issue —
urllib.parse.quoteurlencodePayFast, if you're reading this: please test your Node.js example code against your own live servers. One working, tested example would save every SA developer hours of frustration.
I'm Darren, a freelance web developer in Cape Town. I build Shopify stores, custom web apps, and things that actually work. If you need help with PayFast integration or anything else, get in touch.