PayFast — payment gateway integration profile
Also known as: Payfast
Payfast by Network
PayFast by Network
Category: sa-gateway
Regions: South Africa
Countries: ZA
Currencies: ZAR
Payment methods: card
eft
masterpass
snapscan
zapper
mobicred
moretyme
capitec_pay

Authentication:
model: hash-signed
key_types: merchant_id
merchant_key
passphrase
sandbox_instant: yes
kyc_required_for_live: yes


Payment initiation:
flow: form-post-redirect
endpoint: POST https://www.payfast.co.za/eng/process (live) | https://sandbox.payfast.co.za/eng/process (test)
http_method: POST
required_params: merchant_id
merchant_key
amount
item_name
signature
returns: redirect-to-hosted-page
signature
amount_unit: decimal-2dp


Status handling:
mechanisms: webhook
redirect-return
webhook_verification: ITN (Instant Transaction Notification) POST to notify_url — FOUR mandatory checks: (1) recompute MD5 signature over the RECEIVED-order fields (PHP urlencode, +passphrase if set) vs posted 'signature'; (2) source-IP allowlist DNS-resolved from www/sandbox/w1w/w2w.payfast.co.za; (3) postback the untouched payload to /eng/query/validate and require body 'VALID'; (4) merchant_id matches AND amount_gross within 0.01 of expected. Must return HTTP 200. A redirect return is NEVER proof of payment.
raw_status_values: COMPLETE
PENDING
FAILED
CANCELLED
status_map: COMPLETE: paid
PENDING: pending
FAILED: failed
CANCELLED: failed

Capabilities:
redirect: yes
poll: no
webhook: yes
mobile_push: no
refund: yes
recurring: yes

Credentials required:
key: merchantId
label: Merchant ID
secret: no
note: PayFast dashboard → Settings → Integration. Separate values per environment (sandbox vs live).
key: merchantKey
label: Merchant Key
secret: yes
note: PayFast dashboard → Settings → Integration. Separate per environment.
key: passphrase
label: Security Passphrase (Salt)
secret: yes
note: Optional but REQUIRED for subscriptions/tokenization. If set on the account it MUST be appended to EVERY signature, both checkout and ITN, or the hash won't match.

Sandbox:
test_credentials: merchant_id: 10000100
merchant_key: 46f0cd694581a
note: Widely-cited PayFast sandbox demo credentials — rotate/regenerate your own at sandbox.payfast.co.za (login by email, generated instantly, no KYC). Sandbox simulates the hosted page and always succeeds.
notes: Sandbox has NO public test card numbers — it simulates the hosted payment page (choose the outcome) rather than taking real card input. Recurring/subscriptions are NOT testable in sandbox (once-off only).

Known pain points and mitigations:
problem: Signature mismatch — the #1 PayFast integration failure
cause: JavaScript encodeURIComponent() is NOT PHP urlencode(): it leaves ! ' ( ) * ~ unescaped, uses %20 for spaces (PHP uses +), and casing can differ. Signing with it produces a hash PayFast rejects.
mitigation: Use ONE canonical phpUrlEncode() (encodeURIComponent then replace !→%21 '→%27 (→%28 )→%29 *→%2A ~→%7E and %20→+, uppercase hex) for BOTH checkout signing and ITN verify. Ship a signature-debug tool that returns the exact hashed param string.
severity: critical
problem: Field-order ambiguity in the signature
cause: Checkout must sign fields in PayFast's DOCUMENTED order (not alphabetical, not insertion order), but the ITN must be signed in the order the fields were RECEIVED. Mixing them up breaks the hash.
mitigation: Hardcode the documented checkout order; for the ITN, parse the raw body preserving received order and sign that. Never re-order the ITN fields.
severity: high
problem: Passphrase handling is asymmetric or forgotten
cause: The passphrase (Salt) is per-merchant and optional, but if it's set on the account it MUST be appended (&passphrase=urlencode(value)) to every signature. Omitting it on one side (or adding it when unset) breaks the hash.
mitigation: Treat passphrase as a nullable per-merchant credential and apply it symmetrically to checkout AND ITN signatures — append only when non-blank.
severity: high
problem: Legitimate '0' values get stripped from the signature
cause: Filtering blank fields on JS truthiness drops '0' — but subscription cycles=0 (indefinite) and other zero values are valid and MUST be signed.
mitigation: Filter on '' / null / undefined (blank-ness after trim), NEVER on truthiness, so '0' survives into the signature.
severity: medium
problem: Trusting the browser return / skipping ITN validation checks
cause: Treating the return_url redirect (or an unvalidated ITN) as proof of payment lets an attacker spoof a 'paid' outcome.
mitigation: Only a fully-validated ITN is proof: enforce all four checks (signature, source-IP allowlist via DNS, postback expecting 'VALID', amount+merchant_id match) non-bypassably, return 200 immediately and process asynchronously.
severity: critical
problem: ITN rejected by hardcoded source-IP allowlist
cause: PayFast rotates the IPs its ITN servers send from; a static IP allowlist eventually blocks real notifications.
mitigation: DNS-resolve the valid hosts (www/sandbox/w1w/w2w.payfast.co.za) at request time and test membership — never hardcode IPs.
severity: medium
problem: URLs double-encoded in the signature
cause: Pre-encoding return_url/cancel_url/notify_url before they reach the signer, then encoding again, corrupts the hash.
mitigation: Pass raw URLs to the signer and urlencode exactly once inside phpUrlEncode().
severity: medium

SDKs:
node: none official — REST/self-sign (build the form + MD5 signature yourself)
php: payfast/payfast-php-sdk (PHP 8.1+; the official CMS plugins are the reference implementation)
rest_direct_ok: yes

Notes: South Africa only, ZAR only. Payment is a redirect: render a signed hidden form POSTing to /eng/process; PayFast hosts the page; the outcome arrives via ITN. There is NO per-transaction status poll — the ITN is the source of truth. Refunds via dashboard + API; recurring via subscription_type=1 (recurring) or =2 (tokenization/Payvault ad-hoc charges). Signature is MD5 (lowercase hex) — the single most error-prone part; get phpUrlEncode + field order + passphrase exactly right.

Fields not yet cross-checked against official docs: refund API endpoint path (verify before build)
full ITN round-trip (needs a public notify_url)