PayFast Signature Mismatch in Node.js — What the Docs Don't Tell You

March 25, 2026

If 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

encodeURIComponent
handles spaces and special characters differently than PHP's
urlencode
. PayFast's backend expects the PHP version. Their Node.js example code doesn't account for this, so your signature will never match.

The 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()
to build the signature string and compare it against yours.

Here's the thing. JavaScript's

encodeURIComponent
and PHP's
urlencode
produce different output for the same input.

The big one: spaces.

| Character | JS

encodeURIComponent
| PHP
urlencode
| |-----------|------------------------|-----------------| | space |
%20
|
+
| | tilde
~
|
~
(not encoded) |
%7E
| |
!
|
!
(not encoded) |
%21
| |
'
|
'
(not encoded) |
%27
| |
(
|
(
(not encoded) |
%28
| |
)
|
)
(not encoded) |
%29
| |
*
|
*
(not encoded) |
%2A
|

So if your

item_name
is "My Product - Monthly", the signature string will contain:

  • 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

encodeURIComponent
. It works fine for
ProductA
, but the moment your item name has a space or a customer's email has an apostrophe, your checkout breaks silently with a 400 error.

"Just Add .replace(/%20/g, '+')?"

That's what I tried next:

pfOutput += `${key}=${encodeURIComponent(data[key]).replace(/%20/g, '+')}&`;

Still failed.

Replacing

%20
with
+
fixes the space issue, but not the tilde, exclamation mark, single quote, parentheses, or asterisk differences. If any of your field values contain those characters — or if a future customer's email contains a
+
sign — it'll break again.

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-urlencoded
. PayFast then decodes those values before running their signature check. The chain of encode/decode/re-encode can produce different results depending on what's in your data.

Fix 1: Pure Node.js (Replicate PHP's urlencode)

You can bridge the gap between

encodeURIComponent
(RFC 3986) and PHP's
urlencode
by replacing the characters that differ:

function 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

urlencode
matches what PayFast's server expects — because PayFast's server is PHP.

Yes, 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

exec
call during payment initiation, which is already a redirect to an external site.

Other 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
contains
?email=user%40example.com
(already encoded), and then
urlencode
runs on the whole URL, the
%40
becomes
%2540
. Double encoding. PayFast decodes the form value first, getting the raw
%40
, then encodes it to
%2540
for the signature. But you encoded the already-encoded value, getting a different result.

Use 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.za
. You need to create a sandbox account in their developer portal to get sandbox-specific credentials.

Watch out for

0
being falsy. PayFast's signature string excludes empty parameters, but includes parameters with the value
'0'
— like
cycles: '0'
for indefinite subscriptions. Many Node devs filter with
if (value)
which treats
'0'
as truthy (it's a string, so it's fine), but if you convert to a number first or use
parseInt
, you'll accidentally strip it. That's another silent signature mismatch.

Your 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/process
(or
sandbox.payfast.co.za
for testing).

Don't Forget the ITN Handler

PayFast confirms payments via ITN (Instant Transaction Notification) — a POST to your

notify_url
. This is where you actually grant access. The return URL redirect is cosmetic — the user might close their browser before it completes.

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

encodeURIComponent
where it should match PHP's
urlencode
. These are not the same function. Until they fix this, your options are:

  1. Shell out to PHP for signature generation (what I did)
  2. Write a custom JS function that exactly replicates PHP
    urlencode
    behaviour for every edge case
  3. 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.quote
is not the same as PHP
urlencode
either.

PayFast, 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.