Start a Project
All guides

Payments

Stitch Money integration for South African stores.

Stitch is the newer-generation pay-by-bank stack out of South Africa — think open-banking-style connectivity across FNB, Standard Bank, Absa, Nedbank, Capitec and Investec, with a GraphQL API and proper OAuth2. Where Ozow is a one-shot redirect, Stitch gives you PaymentInitiation, LinkPay (tokenised recurring pulls from a linked bank account), and account-to-account refunds. It is the gateway we reach for when a SA merchant needs recurring debit without card rails. This guide covers the client-credentials OAuth flow, the PaymentInitiation GraphQL mutation, and webhook verification.

Updated 15 April 2026 · 13 min read · any · Joshua Kaplan

Prerequisites

  • Stitch merchant account approved (FICA, CIPC, bank letter, product review — enterprise-grade onboarding)
  • OAuth2 client_id and client_secret from the Stitch dashboard
  • A signing key pair registered with Stitch for request signing
  • Publicly reachable HTTPS webhook endpoint
  • Understanding that bank coverage is partial for some smaller banks — confirm your target customer base maps to supported banks

Step 1. Register a Stitch client and generate keys

In the Stitch dashboard, create a client for your production environment and a separate one for test. Generate an RSA key pair, upload the public key, keep the private key in a secret manager. Stitch uses the private key to sign the client-credentials token request.

Step 2. Fetch an access token via client credentials

Stitch uses OAuth2 client credentials with private_key_jwt client assertion. You build a signed JWT, exchange it at the token endpoint, and cache the resulting access token until expiry.

stitch-token.ts
import { SignJWT } from 'jose';
import crypto from 'node:crypto';

export async function getStitchToken(opts: {
  clientId: string;
  privateKeyPem: string;
  audience: string; // 'https://secure.stitch.money/connect/token'
  scope: string;
}) {
  const keyObj = crypto.createPrivateKey(opts.privateKeyPem);

  const jwt = await new SignJWT({ sub: opts.clientId, jti: crypto.randomUUID() })
    .setProtectedHeader({ alg: 'RS256' })
    .setIssuer(opts.clientId)
    .setAudience(opts.audience)
    .setIssuedAt()
    .setExpirationTime('5m')
    .sign(keyObj);

  const body = new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: opts.clientId,
    scope: opts.scope,
    audience: 'client',
    client_assertion_type:
      'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    client_assertion: jwt,
  });

  const res = await fetch(opts.audience, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body,
  });
  return (await res.json()) as { access_token: string; expires_in: number };
}

Step 3. Initiate a payment via GraphQL

Stitch is a GraphQL API. You call the clientPaymentInitiationRequestCreate mutation with the amount, beneficiary (your merchant account), payer reference, and your external reference. The response contains a redirect URL you send the buyer to.

stitch-payment.ts
const PAYMENT_INIT = /* GraphQL */ `
  mutation CreatePaymentRequest(
    $amount: MoneyInput!
    $payerReference: String!
    $beneficiaryReference: String!
    $externalReference: String!
    $beneficiaryName: String!
    $beneficiaryBankId: BankBeneficiaryBankId!
    $beneficiaryAccountNumber: String!
  ) {
    clientPaymentInitiationRequestCreate(
      input: {
        amount: $amount
        payerReference: $payerReference
        beneficiaryReference: $beneficiaryReference
        externalReference: $externalReference
        beneficiary: {
          bankAccount: {
            name: $beneficiaryName
            bankId: $beneficiaryBankId
            accountNumber: $beneficiaryAccountNumber
          }
        }
      }
    ) {
      paymentInitiationRequest { id url }
    }
  }
`;

export async function createStitchPayment(token: string, order: {
  id: string;
  amountRands: number;
}) {
  const res = await fetch('https://api.stitch.money/graphql', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${token}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      query: PAYMENT_INIT,
      variables: {
        amount: { quantity: order.amountRands.toFixed(2), currency: 'ZAR' },
        payerReference: order.id.slice(0, 12),
        beneficiaryReference: order.id.slice(0, 20),
        externalReference: order.id,
        beneficiaryName: 'Your Store Pty Ltd',
        beneficiaryBankId: 'fnb',
        beneficiaryAccountNumber: '62000000000',
      },
    }),
  });
  return res.json();
}

Step 4. Redirect the buyer and let Stitch handle bank auth

Send the buyer to the url from the response. Stitch presents the supported bank list — the user picks theirs, logs in (biometric or OTP), approves the payment, and is returned to your redirect URL.

Step 5. Register and verify webhooks

In the dashboard, configure your webhook URL and receive events for payment statuses. Stitch signs webhook payloads — verify the signature header against the payload using the documented algorithm (check current docs, Stitch has evolved signing).

Step 6. Enable LinkPay for recurring (optional)

If you need recurring debit, request LinkPay on your Stitch profile. The buyer authorises a standing mandate once, and you can then call a separate mutation to pull future payments without re-authentication — ideal for subscription commerce.

Step 7. Run sandbox flows end-to-end

Stitch provides a test environment with simulated banks. Run a successful flow, a cancelled flow, and an insufficient-funds flow. Verify your webhook handler is idempotent and that your order reconciliation matches the Stitch dashboard export.

Step 8. Go live with monitored rollout

Swap client credentials to production, start with one payment method option alongside card, and roll to 100% traffic over a few days once success rates match card. Reconcile daily bank statements against Stitch external references for the first 30 days.

SA gotchas

  • Bank coverage is not uniform — FNB, Standard Bank, Absa, Nedbank and Capitec are well covered; smaller banks may be partial or unsupported. Confirm your audience banks before launch.
  • Client-assertion JWTs are signed with your private key — key rotation is a scheduled operation, not an emergency one. Plan for it.
  • GraphQL errors are returned with HTTP 200. Always inspect the errors array on every response or you will think failed payments succeeded.
  • LinkPay recurring requires explicit buyer consent to a mandate. You cannot silently convert a one-off payer to recurring — a re-authorisation step is mandatory, mirrors EU PSD2 norms.
  • Fees are negotiated and scale with volume (verify current rates with your account manager — pay-by-bank is typically cheaper than card but not a fixed public number).
  • Stitch returns payer details including a masked bank account number. Treat this as personal information under POPIA and store it encrypted or tokenised.

Frequently asked questions

Is Stitch POPIA-compliant?

Stitch is a regulated SA financial services provider and acts as an Operator under POPIA. You still cover the relationship in your privacy notice and data processing agreement — they supply a standard DPA.

Does Stitch support recurring billing?

Yes via LinkPay, which is their tokenised mandate product for pulling future debits from a linked bank account. It is the main reason to pick Stitch over Ozow if you sell subscriptions.

Is 3DS2 relevant?

Not directly — Stitch is a bank-to-bank rail, authentication happens in the buyer's banking app (SCA by design). 3DS applies to card rails only.

Can I refund through Stitch?

Yes, Stitch supports account-to-account refunds. Timing depends on the buyer's bank but is typically within one business day.

How does Stitch compare to Ozow?

Both are instant-EFT style rails. Stitch has a richer GraphQL API, proper OAuth2, LinkPay for recurring, and a developer-first posture. Ozow has broader existing SA merchant footprint and a simpler integration for one-off payments.