Integrate with Stitch's payment and data API for South African commerce. Use this skill whenever the user wants to accept payments in South Africa, process EFT or instant payments, link bank accounts, access financial data, handle ZAR transactions, or work with Stitch in any way. Also trigger for 'Stitch', 'Stitch Money', 'South African payments', 'ZAR checkout', 'instant EFT South Africa', 'LinkPay', 'pay by bank South Africa', 'account linking South Africa', or when the user needs to move money in or pull financial data from South Africa.
Stitch is South Africa's leading pay-by-bank and financial data platform. It enables instant EFT payments (no card network fees), bank account linking, transaction data access, and payouts — all via a modern GraphQL API. It's the go-to for businesses building in the South African financial ecosystem.
You're building something that touches the South African financial system — accepting ZAR payments, pulling bank data for lending or accounting, processing payouts, or building open banking features. Stitch is especially strong for pay-by-bank (instant EFT) which avoids card fees and has higher success rates in SA.
Stitch uses OAuth 2.0 with client credentials:
POST https://secure.stitch.money/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=your_client_id
&client_secret=your_client_secret
&scope=client_paymentrequest client_bankaccountverification
Response:
{
"access_token": "xxxxx",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "client_paymentrequest"
}
Store credentials in env vars: STITCH_CLIENT_ID, STITCH_CLIENT_SECRET.
⚠️ JWT Client Assertion (Recommended for Production): The
client_secretflow above works for some account types, but Stitch recommends JWT client assertion for production integrations. This uses an RS256-signed JWT with your X.509 certificate and private key instead of aclient_secret. To use it, replaceclient_secret=...withclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion={signed_jwt}. Generate the JWT using your private key (RS256) with claims:iss,sub(both =client_id),aud(https://secure.stitch.money/connect/token),jti,iat,exp. See Stitch auth docs for certificate setup. If your integration fails withclient_secret, this is the likely required path.
API URL: https://api.stitch.money/graphql (GraphQL endpoint)
All requests are GraphQL queries/mutations sent as POST to this single endpoint.
Unlike the other Africa Stack skills which use REST, Stitch uses GraphQL. All requests go to one endpoint:
POST https://api.stitch.money/graphql
Authorization: Bearer {access_token}
Content-Type: application/json
{
"query": "mutation { ... }",
"variables": { ... }
}
This initiates an instant EFT payment — the user selects their bank and completes payment via their banking app.
mutation CreatePaymentRequest(
$amount: MoneyInput!,
$payerReference: String!,
$beneficiaryReference: String!,
$externalReference: String,
$expiresAt: DateTime
) {
clientPaymentInitiationRequestCreate(input: {
amount: $amount,
payerReference: $payerReference,
beneficiaryReference: $beneficiaryReference,
externalReference: $externalReference,
expiresAt: $expiresAt
}) {
paymentInitiationRequest {
id
url
amount {
quantity
currency
}
payerReference
beneficiaryReference
expiresAt
status {
__typename
... on PaymentInitiationRequestCompleted {
date
amount { quantity currency }
payer { bankId accountNumber }
}
... on PaymentInitiationRequestPending {
paymentInitiationRequest { id }
}
... on PaymentInitiationRequestExpired {
date
}
... on PaymentInitiationRequestCancelled {
date
reason
}
}
}
}
}
Variables:
{
"amount": {
"quantity": 150.00,
"currency": "ZAR"
},
"payerReference": "Order #123",
"beneficiaryReference": "MyStore-123",
"externalReference": "ext_ref_123",
"expiresAt": "2025-02-28T23:59:59Z"
}
Important: Amounts are in major currency units (Rands, not cents). ZAR 150.00 = 150.00.
Response includes url — redirect the customer there to complete the payment. They select their bank, authenticate with their banking app, and approve the payment.
Expiry: If expiresAt is provided, the payment request automatically transitions to PaymentInitiationRequestExpired at that time if not completed. No webhook is required — the state is determined when you query it.
LinkPay creates a shareable payment link:
mutation CreateLinkPay($input: LinkPayCreateInput!) {
linkPayCreate(input: $input) {
linkPay {
id
url
amount { quantity currency }
status
expiresAt
}
}
}
Variables:
{
"input": {
"amount": { "quantity": 250.00, "currency": "ZAR" },
"title": "Invoice #456",
"description": "Consulting services - January 2025",
"externalReference": "inv_456",
"expiresAt": "2025-02-28T23:59:59Z"
}
}
query GetPaymentRequestStatus($id: ID!) {
node(id: $id) {
... on PaymentInitiationRequest {
id
amount { quantity currency }
expiresAt
status {
__typename
... on PaymentInitiationRequestCompleted {
date
amount { quantity currency }
}
... on PaymentInitiationRequestCancelled {
date
reason
}
... on PaymentInitiationRequestExpired {
date
}
... on PaymentInitiationRequestPending {
paymentInitiationRequest { id }
}
}
}
}
}
Status types: PaymentInitiationRequestPending, PaymentInitiationRequestCompleted, PaymentInitiationRequestCancelled, PaymentInitiationRequestExpired.
Allow users to connect their bank accounts for data access:
mutation CreateAccountLinkingRequest($input: ClientAccountLinkingRequestCreateInput!) {
clientAccountLinkingRequestCreate(input: $input) {
accountLinkingRequest {
id
url
status
}
}
}
Variables:
{
"input": {
"accountFilter": {
"bankIds": ["absa", "fnb", "standardbank", "nedbank", "capitec", "investec", "tymebank", "discovery_bank"]
}
}
}
Redirect user to url — they authenticate with their bank and authorize data access. The linked account ID comes back via webhook (account.linked event).
query GetLinkedAccounts {
user {
bankAccounts {
edges {
node {
id
name
bankId
accountNumber
accountType
currentBalance {
quantity
currency
}
availableBalance {
quantity
currency
}
}
}
}
}
}
query GetTransactions($accountId: ID!, $first: Int, $after: String) {
node(id: $accountId) {
... on BankAccount {
transactions(first: $first, after: $after) {
edges {
node {
id
amount { quantity currency }
date
description
reference
runningBalance { quantity currency }
debitOrCredit
category
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
Variables:
{
"accountId": "account_xxxxx",
"first": 50,
"after": null
}
Uses cursor-based pagination. Pass pageInfo.endCursor as after for next page.
query GetBalance($accountId: ID!) {
node(id: $accountId) {
... on BankAccount {
currentBalance {
quantity
currency
}
availableBalance {
quantity
currency
}
name
bankId
accountType
}
}
}
Before disbursing funds, verify the bank account exists and belongs to the stated owner:
query VerifyBankAccount($input: VerifyBankAccountDetailsInput!) {
verifyBankAccountDetails(input: $input) {
accountHolder {
accountHolderName
accountHolderIdentifier
accountHolderType
}
bankAccount {
bankId
accountNumber
accountType
}
verified
}
}
Variables:
{
"input": {
"bankId": "fnb",
"accountNumber": "1234567890",
"accountHolderIdentifier": "9001010000000",
"accountHolderName": "John Dlamini"
}
}
Send money to a bank account:
mutation CreateDisbursement($input: ClientDisbursementCreateInput!) {
clientDisbursementCreate(input: $input) {
disbursement {
id
amount { quantity currency }
status {
__typename
... on DisbursementInitiated {
initiatedAt
}
... on DisbursementCompleted {
completedAt
reference
}
... on DisbursementFailed {
failedAt
reason
}
}
bankBeneficiary {
bankAccountNumber
bankId
name
accountType
}
}
}
}
Variables:
{
"input": {
"amount": { "quantity": 5000.00, "currency": "ZAR" },
"bankBeneficiary": {
"bankAccountNumber": "1234567890",
"bankId": "fnb",
"name": "John Dlamini",
"accountType": "current"
},
"nonce": "unique_payout_ref_123",
"externalReference": "payout_jan_001"
}
}
Bank IDs: absa, fnb (First National Bank), standardbank, nedbank, capitec, investec, tymebank, discovery_bank.
Configure webhooks in the Stitch dashboard. Verify the signature:
const crypto = require('crypto');
const signature = req.headers['x-stitch-signature'];
const payload = JSON.stringify(req.body);
const expected = crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex');
if (signature !== expected) {
return res.status(401).end();
}
Webhook Events:
payment_initiation_request.authorisation_required — User needs to authorize paymentpayment_initiation_request.completed — Payment successfully processedpayment_initiation_request.cancelled — Payment cancelled by userpayment_initiation_request.expired — Payment request expired before completionaccount_linking_request.completed — Bank account successfully linkedaccount_linking_request.cancelled — Account linking cancelleddisbursement.initiated — Payout sent to bankdisbursement.completed — Payout settled in beneficiary accountdisbursement.failed — Payout failedWebhook Payload Structure:
{
"event_type": "payment_initiation_request.completed",
"event_id": "evt_xxxxx",
"created_at": "2025-02-24T10:30:00Z",
"resource": {
"id": "payment_xxxxx",
"amount": { "quantity": 150.00, "currency": "ZAR" },
"status": "completed",
"payer": { "bankId": "fnb", "accountNumber": "1234567890" }
}
}
CreatePaymentRequest mutation → get redirect URLpayment_initiation_request.completedCreateAccountLinkingRequest → user links bankGetLinkedAccounts → get account IDsGetTransactions → analyze cash flowCreateDisbursement to pay each sellerGraphQL errors come in the response body. Even successful HTTP responses (200) can contain GraphQL errors:
{
"data": {
"clientPaymentInitiationRequestCreate": null
},
"errors": [
{
"message": "Invalid amount",
"extensions": {
"code": "VALIDATION_ERROR",
"path": ["clientPaymentInitiationRequestCreate"]
}
}
]
}
Always check the errors array — a 200 response doesn't mean success in GraphQL.
Common error codes:
UNAUTHORIZED — Invalid or expired token, or missing scopeINVALID_SCOPE — Token missing required scope for operationVALIDATION_ERROR — Invalid input (amount, references, etc.)PAYMENT_REQUEST_NOT_FOUND — Bad payment request IDBANK_ACCOUNT_NOT_FOUND — Bad account ID or user not authorizedINSUFFICIENT_FUNDS — Not enough balance for disbursementINVALID_BANK_ACCOUNT — Bad account number or bank ID (verify with verifyBankAccountDetails first)RATE_LIMITED — Too many requests (implement exponential backoff)INTERNAL_SERVER_ERROR — Stitch server error (retry with backoff)Error Handling Pattern:
async function executeGraphQL(query, variables) {
const response = await fetch('https://api.stitch.money/graphql', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ query, variables })
});
const result = await response.json();
// Check both HTTP status AND GraphQL errors
if (!response.ok || result.errors) {
const error = result.errors?.[0];
console.error('GraphQL Error:', error?.message);
console.error('Error Code:', error?.extensions?.code);
throw new Error(error?.message || 'GraphQL request failed');
}
return result.data;
}
https://api.stitch.money/graphqlerrors array — always check both__typename (like ... on PaymentInitiationRequestCompleted) are required to handle union typesexpiresAt on a payment request, it automatically transitions to PaymentInitiationRequestExpired when the deadline passesclient_secret securely (environment variables, not hardcoded)expires_in: 3600) — cache tokens and refresh before expiryclient_paymentrequest, client_bankaccountverification, client_disbursementnull or an error like "insufficient scope", add the missing scope to your token requestaccountLinkingRequest.url is required — there's no programmatic account linkingaccount.linked event) after user authorizationuser.bankAccounts.edges to get all of themcategory field) is provided by Stitch, not the bankafter, endCursor) — don't use offset-based paginationverifyBankAccountDetails queryaccountHolderIdentifier must match the identity or business registration numberMoneyInput with quantity (decimal) and currency (e.g., "ZAR")"quantity": 150.00, not 15000event_idevent_id in your database and skip duplicate webhook deliveriesRATE_LIMITED errorWhen working with SA bank accounts, you'll encounter:
fnb, absa, standardbank, nedbank, capitec, investec, tymebank, discovery_bank)current, savings, cheque