Bank Account Verification REST API
Stitch’s Bank Account Verification (BAV) service is available over REST and supports full integration via the REST API.
Please note the following about the Bank Account Verification product:
- BAV is currently only available for South African bank accounts. Identifying documents and business registrations default to country
ZAwhencountryis omitted, explicit non-ZAvalues are passed through to the upstream verifier, though onlyZAdocuments are routinely supported today. - A client token with both the
client_bankaccountverificationandaccountholdersscopes is required.
Please contact support@stitch.money if you have any questions about access.
Use the Stitch REST API URL https://api.stitch.money/v2 for all requests on both test and live clients. Standard HTTP status conventions are used for all API responses.
Authentication
A client token is required in order to verify a bank account. Follow the steps described in the client token guide to obtain a client token with the following scopes:
client_bankaccountverification— grants access to the bank-account-verification endpoint.accountholders— required to read the verifiedidentifyingDocumentfields (ID number / passport number) on the response. Without it, the API returns403 ForbiddenwithmissingScopes: ["accountholders"].
Business verifications technically only need the accountholders scope when individual identifying documents are involved, but Stitch requires both scopes uniformly for predictable error handling.
Verifying a Bank Account
Initiate a POST request to /v2/bank-account-verifications to verify that a bank account exists, is open, and that the account holder details match the identity (or business registration) information you have on record.
curl -X POST 'https://api.stitch.money/v2/bank-account-verifications' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"bankAccount": {
"bank": "absa",
"number": "1234567890",
"type": "current"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "JD",
"identifyingDocument": {
"type": "identity_document",
"number": "9001015009087"
}
}
}'
Request body
The request body has two top-level fields: bankAccount and accountHolder. Both are required.
bankAccount
| Field | Type | Description |
|---|---|---|
bank | string | The bank where the account is held. One of: absa, african_bank, capitec, discovery_bank, fnb, grindrod_bank, investec, nedbank, sasfin_bank, standard_bank, tymebank, za_access_bank, za_albaraka_bank, za_bank_zero, za_bidvest, za_standard_chartered_bank. |
branchCode | string (optional) | The 6-digit branch code. When omitted, the universal branch code for the supplied bank is used. |
number | string | The bank account number. Numeric digits only, 6–13 characters. |
type | string (optional) | The type of bank account. One of:current, savings, credit, loan, investment, other, unknown. Will be verified if provided. |
If you have a branch code on file and wish to verify with it specifically, supply branchCode alongside bank. Otherwise, supplying bank alone is sufficient — the universal branch code will be used automatically.
accountHolder
accountHolder is a discriminated object identified by the type field (individual or business).
accountHolder for an individual
When type is individual:
| Field | Type | Description |
|---|---|---|
type | string | Must be individual. |
familyName | string (optional) | Surname or family name of the individual (max 60 chars). Will be verified if provided. |
initials | string (optional) | Initials of the account holder, e.g. "JD" (max 5 chars). Will be verified if provided. |
identifyingDocument | object | The identifying document for the individual. See below. |
The identifyingDocument object accepts:
| Field | Type | Description |
|---|---|---|
type | string | The document type. One of identity_document (for South African ID numbers) or passport. |
country | string (optional) | ISO 3166-1 alpha-2 country code where the document was issued. Defaults to ZA when omitted. |
number | string | The document number. |
BAVS verification is only available for South African bank accounts. The country field is exposed for forward compatibility and to align with the upstream contract — today, only ZA documents are routinely verified by the upstream provider.
accountHolder for a business
When type is business:
| Field | Type | Description |
|---|---|---|
type | string | Must be business. |
registrationNumber | string | The business registration number. |
country | string (optional) | ISO 3166-1 alpha-2 country code where the business is registered. Defaults to ZA when omitted. |
name | string (optional) | The registered business name (max 60 chars). Will be verified if provided. |
Verification Examples
Individual (Local) Account
For South African users, verify the bank account against South African identity document information. Within accountHolder, set type to individual and supply identifyingDocument with type: identity_document:
curl -X POST 'https://api.stitch.money/v2/bank-account-verifications' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"bankAccount": {
"bank": "absa",
"number": "1234567890",
"type": "current"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "JD",
"identifyingDocument": {
"type": "identity_document",
"number": "9001015009087"
}
}
}'
Example 200 OK response:
{
"id": "bav_1Q0PsIJvEtkwdCNYMSaVuR",
"bankAccount": {
"bank": "absa",
"branchCode": "632005",
"number": "1234567890",
"type": "current"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "JD",
"identifyingDocument": {
"type": "identity_document",
"country": "ZA",
"number": "9001015009087"
}
},
"verificationResult": {
"outcome": "verified",
"bankAccount": {
"exists": "verified",
"typeMatch": "verified",
"isOpen": true,
"isOpenForMoreThanThreeMonths": true,
"acceptsDebits": true,
"acceptsCredits": true
},
"accountHolder": {
"identifyingDocument": "verified",
"familyName": "verified",
"initials": "verified"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Individual (Foreign) Account
For foreign users holding South African bank accounts, verify against passport document information. Supply identifyingDocument with type: passport and the issuing country (ISO 3166-1 alpha-2):
curl -X POST 'https://api.stitch.money/v2/bank-account-verifications' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"bankAccount": {
"bank": "fnb",
"number": "1234567890",
"type": "savings"
},
"accountHolder": {
"type": "individual",
"familyName": "Smith",
"initials": "JS",
"identifyingDocument": {
"type": "passport",
"country": "GB",
"number": "A12345678"
}
}
}'
Example 200 OK response:
{
"id": "bav_2eyfk4nQpHs7iWQ4QwjJK5",
"bankAccount": {
"bank": "fnb",
"number": "1234567890",
"type": "savings"
},
"accountHolder": {
"type": "individual",
"familyName": "Smith",
"initials": "JS",
"identifyingDocument": {
"type": "passport",
"country": "GB",
"number": "A12345678"
}
},
"verificationResult": {
"outcome": "verified",
"bankAccount": {
"exists": "verified",
"typeMatch": "verified",
"isOpen": true,
"isOpenForMoreThanThreeMonths": true,
"acceptsDebits": true,
"acceptsCredits": true
},
"accountHolder": {
"identifyingDocument": "verified",
"familyName": "verified",
"initials": "verified"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Business Account
For business accounts, verify against business registration information. Set accountHolder.type to business:
curl -X POST 'https://api.stitch.money/v2/bank-account-verifications' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"bankAccount": {
"bank": "nedbank",
"number": "9876543210",
"type": "current"
},
"accountHolder": {
"type": "business",
"registrationNumber": "2020/123456/07",
"country": "ZA",
"name": "Acme Trading (Pty) Ltd"
}
}'
Example 200 OK response. Note that verificationResult.accountHolder carries only the keys that apply to a business (registrationNumber, name) — individual-only keys like familyName and initials are omitted entirely rather than returned as null.
{
"id": "bav_3RxGl8mTuSqXyZ1abc2DEF",
"bankAccount": {
"bank": "nedbank",
"number": "9876543210",
"type": "current"
},
"accountHolder": {
"type": "business",
"registrationNumber": "2020/123456/07",
"country": "ZA",
"name": "Acme Trading (Pty) Ltd"
},
"verificationResult": {
"outcome": "verified",
"bankAccount": {
"exists": "verified",
"typeMatch": "verified",
"isOpen": true,
"isOpenForMoreThanThreeMonths": true,
"acceptsDebits": true,
"acceptsCredits": true
},
"accountHolder": {
"registrationNumber": "verified",
"name": "verified"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Refuted Account (Name Mismatch)
If the bank confirms the account exists but the holder's name does not match the supplied name, the response surfaces outcome: refuted. The per-field verdicts under verificationResult.accountHolder identify which fields did not match, while remaining checks still report what could be verified.
curl -X POST 'https://api.stitch.money/v2/bank-account-verifications' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"bankAccount": {
"bank": "capitec",
"number": "1234567890",
"type": "savings"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "J",
"identifyingDocument": {
"type": "identity_document",
"number": "9001015009087"
}
}
}'
Example 200 OK response (verificationResult.accountHolder.familyName is refuted and outcome rolls up to refuted):
{
"id": "bav_3RxGl8mTuSqXyZ1abc2DEF",
"bankAccount": {
"bank": "capitec",
"number": "1234567890",
"type": "savings"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "J",
"identifyingDocument": {
"type": "identity_document",
"country": "ZA",
"number": "9001015009087"
}
},
"verificationResult": {
"outcome": "refuted",
"bankAccount": {
"exists": "verified",
"typeMatch": "verified",
"isOpen": true,
"isOpenForMoreThanThreeMonths": true,
"acceptsDebits": true,
"acceptsCredits": true
},
"accountHolder": {
"identifyingDocument": "verified",
"familyName": "refuted",
"initials": "verified"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Refuted Account (Closed)
A more subtle refuted case: every per-field verdict passes — identity, name, initials, account exists, type matches — but the bank reports the account is closed. The API still returns outcome: refuted because a closed account is not safely usable for any downstream operation (payouts, debits, credits). The bankAccount.isOpen === false signal is what drives the override; the per-field verdicts remain verified so callers can still see exactly what matched.
{
"id": "bav_6KkLm3oPqRsT0bYdef5KLM",
"bankAccount": {
"bank": "fnb",
"branchCode": "250655",
"number": "1234567890",
"type": "current"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "J",
"identifyingDocument": {
"type": "identity_document",
"country": "ZA",
"number": "9001015009087"
}
},
"verificationResult": {
"outcome": "refuted",
"bankAccount": {
"exists": "verified",
"typeMatch": "verified",
"isOpen": false,
"isOpenForMoreThanThreeMonths": false,
"acceptsDebits": false,
"acceptsCredits": false
},
"accountHolder": {
"identifyingDocument": "verified",
"familyName": "verified",
"initials": "verified"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Indeterminate Result
When the bank cannot perform the verification — for example because the bank's verification system is temporarily unavailable, or the bank does not support a particular check — the response surfaces outcome: indeterminate. Per-field verdicts that the bank could not evaluate are indeterminate; bank-returned facts that the bank did not provide a value for are null.
{
"id": "bav_4SyHm9nUvTrYzA2cde3FGH",
"bankAccount": {
"bank": "za_albaraka_bank",
"branchCode": "800000",
"number": "5678901234",
"type": "current"
},
"accountHolder": {
"type": "individual",
"familyName": "Doe",
"initials": "J",
"identifyingDocument": {
"type": "identity_document",
"country": "ZA",
"number": "9001015009087"
}
},
"verificationResult": {
"outcome": "indeterminate",
"bankAccount": {
"exists": "indeterminate",
"typeMatch": "indeterminate",
"isOpen": null,
"isOpenForMoreThanThreeMonths": null,
"acceptsDebits": null,
"acceptsCredits": null
},
"accountHolder": {
"identifyingDocument": "indeterminate",
"familyName": "indeterminate",
"initials": "indeterminate"
}
},
"createdAt": "2024-01-15T09:30:00Z",
"updatedAt": "2024-01-15T09:30:00Z"
}
Response Resource
Every successful response is a Bank Account Verification resource:
id— Unique identifier for this verification, in the formbav_<22-character-base62>. Use this when contacting support.bankAccount— The bank, branch code, number, and type as resolved by the verifier. Mirrors the requestbankAccount.accountHolder— The verified account holder, either anindividualor abusiness. Mirrors the requestaccountHolder(the per-field verification verdicts live underverificationResult.accountHolder, not here).verificationResult— The verification outcome (see below).createdAt— UTC timestamp of when the verification was performed.updatedAt— Always equal tocreatedAt. Bank account verifications are immutable; the field is included to align with the standard resource shape.
Verdict values
verificationResult.outcome and every nested verdict (verificationResult.bankAccount.exists, verificationResult.accountHolder.familyName, …) is one of three values:
verified— The data provided was successfully verified against the bank's data.refuted— The data provided is not correct. It failed to match the data the verifier holds for the account, or the field itself is invalid or non-existent.indeterminate— Verification could not be performed conclusively for the field. The bank may not support the check, the bank's verification system may be temporarily unavailable, or the caller may not have supplied the input needed to perform the check.
Verdicts are never null. When the bank could not return a verdict, the field is indeterminate.
Bank-returned facts
The booleans under verificationResult.bankAccount (isOpen, isOpenForMoreThanThreeMonths, acceptsDebits, acceptsCredits) are boolean | null. null means the bank did not return a value for that fact — typical when the verification as a whole is indeterminate.
Distinguish carefully between false and null:
isOpen: false— the bank actively reported that the account is closed. This is a definitive negative signal.isOpen: null— the bank did not report a status for openness. Treat as unknown, not as "open".
verificationResult.outcome
outcome is the rolled-up decision for the verification, derived from all of the verdicts and the bank-returned facts. The derivation rule is:
refuted— if any verdict underverificationResult.bankAccountorverificationResult.accountHolderisrefuted, OR if the bank reportsverificationResult.bankAccount.isOpen === false(the account is closed and therefore not safely usable).indeterminate— otherwise, if any verdict isindeterminateand no verdict isrefuted.verified— otherwise.
The closed-account override (rule 1, second clause) is why a verification can return outcome: refuted even when every per-field verdict is verified. See the closed-account example above.
verificationResult.bankAccount
Per-check verdicts and bank-returned facts about the bank account itself:
exists— Whether the account exists at the bank.typeMatch— Whether the account type matches.indeterminatewhen the caller did not supplybankAccount.typein the request.isOpen— Whether the account is currently open at the bank.isOpenForMoreThanThreeMonths— Whether the account has been open for longer than three months. A common risk signal.acceptsCredits— Whether the account accepts credit payments (i.e. can be paid to). For frozen or closed accounts this isfalse.acceptsDebits— Whether the account accepts debit payments (i.e. can make payments). For frozen or closed accounts this isfalse.
verificationResult.accountHolder
Per-field verdicts about the account holder. Only the keys relevant to the holder type are present.
For accountHolder.type === "individual":
identifyingDocument— Whether the supplied identity number or passport number matches the account holder on record.familyName— Whether the supplied family name matches the account holder on record.initials— Whether the supplied initials match the account holder on record.
For accountHolder.type === "business":
registrationNumber— Whether the supplied business registration number matches the account holder on record.name— Whether the supplied registered business name matches the account holder on record.
Individual-only keys are omitted from a business response (and vice versa) rather than returned as null. The shape mirrors the request: if a field was not relevant to the holder type, it simply isn't there.
Handling Pending Verifications
In some cases, the upstream verification provider may take up to 120 seconds to produce a result, depending on the bank selected. When this happens, the REST API returns 202 Accepted:
{
"status": "pending",
"message": "Verification is in progress. Resubmit the same request to check for results."
}
Resubmit the same request body to poll for the result. Results are stored for up to 48 hours following the initial request, so you will not be billed for subsequent lookups of the same data within this period.
The 202 response may also be returned from the test environment to simulate the random occurrences that may be expected in production.
Error Reference
| Status Code | Meaning | Description |
|---|---|---|
200 | OK | Verification completed successfully. The response body contains the verification result resource. |
202 | Accepted | Verification is still in progress. Resubmit the same request to poll for the result. |
400 | Bad Request | The request body is malformed or fails schema validation (e.g. bankAccount.number is missing, bankAccount.bank is missing). |
401 | Unauthorized | The access token is missing, expired, or invalid. |
403 | Forbidden | The access token is missing one or both of the required scopes (client_bankaccountverification, accountholders). The response includes a missingScopes array. |
422 | Unprocessable Entity | The request was valid, but verification could not be completed due to a known business-logic reason. See Verification Errors below. |
503 | Service Unavailable | The verification service is temporarily unavailable, typically due to upstream maintenance. |
Verification Errors
A 422 response indicates that the upstream BAVS provider rejected the verification for a known reason. The body has the shape:
{
"title": "Verification Error",
"code": "VERIFICATION_ERROR",
"detail": "The provided account number is not valid at the selected bank.",
"reason": "invalid_account_number"
}
The reason field is one of the following values from the upstream VerificationErrorReason enum:
technical_error— A technical failure prevented verification.invalid_account_type— The suppliedbankAccount.typeis not valid for this bank or account.invalid_branch_number— The suppliedbranchCode(or universal code derived frombank) is not valid.invalid_account_number— The suppliedbankAccount.numberis not valid at the selected bank.invalid_account_number_length— The suppliedbankAccount.numberis the wrong length for this bank.instituition_not_supported— The bank is not supported by the upstream verifier.bond_account_not_allowed— The supplied account is a bond account, which is not supported by the upstream verifier.
Validation Errors
A 400 response indicates that the request body failed gateway-side schema validation before being forwarded to BAVS. The body has the shape:
{
"title": "Invalid Input",
"code": "BAD_USER_INPUT",
"detail": "Invalid request body: bankAccount.number is required"
}
Common causes:
bankAccount.numbermissing or not 6–13 digits.bankAccount.bankmissing or not a recognised bank enum value.accountHolder.typemissing or not one ofindividual/business.bankAccount.branchCodeis not exactly 6 numeric digits.countryis not a valid ISO 3166-1 alpha-2 code.
400 responses may also surface upstream resolver-level validation failures (for example, an invalid ZA identity number, or a malformed business registration number).
Notes and Limitations
- South Africa only. BAV is currently only available for South African bank accounts. The
countryfield on identifying documents and business registrations is exposed for forward compatibility but defaults toZA; verification of non-ZA documents is governed by upstream support. bav_<id>is a gateway-minted identifier, not (yet) a queryable resource. Use it for support correlation. A follow-up release will swap it for the persisted upstream id.- Polling required for
202. When the upstream provider returns a pending result, resubmit the same request body to retrieve the final outcome.
Validation Differences from GraphQL
The REST surface is deliberately stricter than the equivalent GraphQL mutation in a few places. These tightenings are designed to catch obviously-malformed requests before they reach the upstream provider, reducing wasted calls and producing predictable 400 errors instead of opaque downstream failures.
bankAccount.number— REST enforces a 6-character minimum (in addition to the shared 13-character maximum). GraphQL only enforces the maximum.bankAccount.bank— REST requiresbankon every request. GraphQL allowsbranchCodeas an alternative tobankId.bankAccount.branchCode— REST enforces exactly 6 numeric digits via^\d{6}$when provided. GraphQL accepts 1–6 numeric digits.
All other validations (character sets on names, regex on business registration numbers, country-specific ID number checksums) run upstream and surface as 400 Bad Request with the upstream's specific message when they fail.
Simulating Verification in Test
How test mode is enabled
There is no test-mode flag on the BAVS request body. The verification path is selected by your client token: tokens issued to a test client (whose client_id is prefixed with test-) are automatically routed to the simulator instead of the live verification provider. The REST endpoint shares this routing with the equivalent GraphQL mutation — the token identifies whether the request is a test request, and the upstream service dispatches accordingly. Live-client tokens always reach the live provider; test-client tokens never do.
A consequence of this design:
- Authenticate with a test-client OAuth token to hit the simulator. Use the same
client_bankaccountverificationandaccountholdersscopes as a live request. - The request body shape is identical for test and live requests. The values in the table below only steer the simulator's response (
verifiedvsrefuted) — they are not sentinel values that opt you into test mode. - Test-mode requests are billed at the simulator's tariff (not the live tariff) and never contact the upstream verification provider.
When integrating with your test client, mock information can be specified over the API to simulate verified or refuted results.
To simulate a successful individual account verification, whether for an identity document holder or a passport holder, include the following values in your test request:
| Parameter | Value | Identifying Document Type |
|---|---|---|
accountHolder.initials | J | ID Document / Passport |
accountHolder.familyName | Clegg | ID Document / Passport |
identifyingDocument.number (ID) | 5306075800082 | ID Document |
identifyingDocument.number (PP) | A12345678 | Passport |
bankAccount.number | Any account number ending in 0 | ID Document / Passport |
Use either passport or ID document as the identifying document, not both.
To simulate a successful business account verification, include the following values in your test request:
| Parameter | Value |
|---|---|
accountHolder.name | Johnny Clegg Recordings Ltd |
accountHolder.registrationNumber | 1234/567891/23 |
bankAccount.number | Any account number ending in 0 |
Varying the inputs from the values above will result in refuted outcomes being returned for the changed fields.
The 202 Accepted (pending) response may be returned from any requests in the test environment. This simulates the random occurrences that may be expected in the live environment. On receiving this response, resubmit your request to receive the final verification result.