Payments
Payfast Shopify integration for South African stores.
Payfast has been the default Shopify payment rail for most South African merchants since the early 2010s, long before Stripe opened a local entity. It covers card, Instant EFT, Mobicred, SnapScan and Masterpass from a single checkout, but Shopify does not ship a native Payfast app — you integrate it as a Manual Payment Method or through a third-party bridge. This guide walks through the direct cart redirect flow, signature generation, the ITN (Instant Transaction Notification) callback, and the sandbox-to-live swap every ZA merchant trips over on launch day.
Prerequisites
- Active Payfast merchant account (CIPC docs + FICA verified — allow 2–5 business days)
- Shopify plan that allows custom payment methods (all plans include Manual Payment Methods; Shopify Plus needed for true custom gateway apps)
- Publicly reachable HTTPS callback URL for ITN (localhost tunnels will not work in Live mode)
- merchant_id, merchant_key and passphrase from the Payfast dashboard
- South African bank account linked to your Payfast profile for settlement
Step 1. Create the Payfast merchant profile and capture credentials
Complete FICA in the Payfast dashboard (ID, proof of address, bank confirmation, CIPC for Pty Ltds). Once approved, go to Settings → Integration and copy your live merchant_id, merchant_key and set a strong passphrase. Keep the sandbox credentials from sandbox.payfast.co.za in a separate .env so you never ship sandbox keys to production.
Step 2. Decide on the integration pattern
For most Shopify stores on Basic/Advanced plans, you use the cart-redirect pattern exposed as a Manual Payment Method plus a small external checkout page on your own domain or Workers endpoint. Shopify Plus merchants can build a true external payment app, but it is rarely worth the overhead for a ZA-only catalogue.
Step 3. Generate the Payfast signature
Every request to process.payfast.co.za must be signed. Sort all non-empty fields alphabetically, URL-encode with uppercase hex and spaces as +, append &passphrase=..., then MD5. Do this server-side only — never in the storefront.
import crypto from 'node:crypto';
export function payfastSignature(
data: Record<string, string>,
passphrase: string,
) {
const ordered = Object.keys(data)
.filter((k) => data[k] !== '' && data[k] != null)
.sort()
.map(
(k) =>
`${k}=${encodeURIComponent(data[k].trim()).replace(/%20/g, '+')}`,
)
.join('&');
const withPass = passphrase
? `${ordered}&passphrase=${encodeURIComponent(passphrase.trim()).replace(
/%20/g,
'+',
)}`
: ordered;
return crypto.createHash('md5').update(withPass).digest('hex');
} Step 4. Build the checkout redirect form
Post the order to process.payfast.co.za (live) or sandbox.payfast.co.za (test). Use amount with two decimals and a stable m_payment_id equal to your Shopify order name so ITN reconciliation works.
<form action="https://www.payfast.co.za/eng/process" method="post">
<input type="hidden" name="merchant_id" value="10000100" />
<input type="hidden" name="merchant_key" value="46f0cd694581a" />
<input type="hidden" name="return_url" value="https://yourstore.co.za/payfast/return" />
<input type="hidden" name="cancel_url" value="https://yourstore.co.za/payfast/cancel" />
<input type="hidden" name="notify_url" value="https://yourstore.co.za/payfast/itn" />
<input type="hidden" name="m_payment_id" value="SHOPIFY-1001" />
<input type="hidden" name="amount" value="499.00" />
<input type="hidden" name="item_name" value="Order #1001" />
<input type="hidden" name="signature" value="__computed__" />
<button type="submit">Pay with Payfast</button>
</form> Step 5. Handle the ITN callback correctly
Payfast sends an ITN POST to notify_url after payment. The correct pattern is: respond with HTTP 200 immediately, then verify the signature, validate the source IP (payfast.co.za IPs), re-POST the body to Payfast's validate endpoint, and only then mark the Shopify order as paid. Silent retries happen for up to 2 days — your handler must be idempotent.
export async function handleItn(req: Request, passphrase: string) {
const body = await req.formData();
const data = Object.fromEntries(body.entries()) as Record<string, string>;
const received = data['signature'];
delete data['signature'];
// 1. Acknowledge FIRST so Payfast does not retry
const ack = new Response('OK', { status: 200 });
// 2. Verify signature
const expected = payfastSignature(data, passphrase);
if (expected !== received) return ack;
// 3. Re-POST to Payfast for server-side validation
const verify = await fetch('https://www.payfast.co.za/eng/query/validate', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(data as Record<string, string>),
});
const text = await verify.text();
if (!text.startsWith('VALID')) return ack;
if (data.payment_status === 'COMPLETE') {
await markShopifyOrderPaid(data.m_payment_id, data.pf_payment_id);
}
return ack;
} Step 6. Add the Manual Payment Method in Shopify
Shopify admin → Settings → Payments → Manual payment methods → Create custom. Name it "Payfast", add your payment instructions (copy buyers see), and save. On checkout, buyers pick Payfast; your theme customisation or checkout extension forwards them to the redirect form.
Step 7. Test in sandbox end-to-end
Swap the action URL to sandbox.payfast.co.za/eng/process and use sandbox merchant_id 10000100 / merchant_key 46f0cd694581a. Test every outcome: COMPLETE, FAILED, and the cancel flow. Confirm your ITN handler is idempotent by replaying the notification.
Step 8. Flip to live and monitor the first 20 orders
Swap credentials and URLs to live, deploy, and manually reconcile the first 20 orders against the Payfast dashboard. This is the fastest way to catch signature whitespace bugs or rounding errors (ZAR subtotals with 5%+VAT can drift by a cent if you round in the theme).
SA gotchas
- Reply 200 BEFORE running verification on ITN, otherwise Payfast retries aggressively and you double-credit orders if your handler is not idempotent.
- Fees are approximately 3.5% + R2 on card (verify current rates — Payfast adjusts tiers by volume and for micropayments under R20). Instant EFT is cheaper but has no chargeback protection.
- No chargeback mechanism exists for Instant EFT — if you sell high-ticket items, require card-only for orders above a threshold or you inherit the fraud risk.
- Signature generation is whitespace-sensitive. Trim every field. The most common support ticket we see is a trailing newline in item_description breaking MD5.
- Sandbox and live use different domains AND different credentials — hardcoding either one will silently break production.
- Shopify Manual Payment Method does not auto-capture — your ITN handler must call the Shopify Admin API to mark the order paid.
Frequently asked questions
Is Payfast POPIA-compliant for storing card data?
Payfast is PCI-DSS Level 1 and holds card data on their side — your Shopify store never sees the PAN, which keeps you out of PCI scope and limits the personal information you process under POPIA. You still need a privacy notice covering the redirect.
Does Payfast support recurring billing for subscriptions?
Yes — via their subscription/tokenisation product you can charge stored cards on a schedule. It is a separate activation on your merchant profile, and Shopify subscription apps need a custom bridge since Shopify Subscriptions only natively supports Shopify Payments.
Is 3DS2 mandatory on Payfast?
Visa and Mastercard have mandated 3DS2 on South African e-commerce since 2021. Payfast handles the challenge flow on their hosted page — you do not need to implement it, but expect a small percentage of drop-off on the 3DS step.
How long does settlement take?
Standard settlement is T+1 to T+2 business days for card and Instant EFT into your linked ZA bank account. New merchants can have a rolling reserve for the first 60–90 days.
Can I use Payfast without Shopify's Manual Payment Method?
On Shopify Plus yes — you can build a proper external payment app that shows inside checkout. On every other plan the Manual Payment Method plus redirect is the only supported path.