> For the complete documentation index, see [llms.txt](https://developers.stub.africa/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.stub.africa/oauth-data-sync.md).

# OAuth data sync

#### Overview

Use OAuth data sync to push bank data into a customer's stub business. The customer starts the connection in your portal, signs in to stub, selects a business, and grants consent.

stub never receives banking credentials or accesses banking screens.

{% hint style="info" %}
**Before you begin:** You need a client ID, client secret, and allowlisted return addresses. Your integration contact issues these values.\
\
Use `https://test.api.stub.africa` for sandbox requests. Every endpoint on this page is relative to this URL. Sandbox and production credentials are not interchangeable.
{% endhint %}

#### How it works

You are a confidential OAuth client of stub's authorisation server. Your client secret is required whenever you issue, refresh, or revoke a token. Access tokens only authorise data sync requests.

```mermaid
sequenceDiagram
    autonumber
    actor C as Customer
    participant B as Your online banking
    participant S as stub
    C->>B: Clicks "Link to stub"
    B->>S: Opens the consent page
    S->>C: Sign in, pick a business
    C->>S: Approve
    S-->>B: One-time code
    B->>S: Code + secret → tokens
    B->>S: Push accounts
    B->>S: Push transactions, and keep pushing
```

#### Link a customer

Send the customer to stub's consent page. They sign in, select a business, and approve the connection. stub redirects them to a registered return address with a single-use authorisation code.

```bash
GET /authorize
  ?client_id=<your client id>
  &response_type=code
  &redirect_uri=https://online.yourbank.example/stub/callback
  &state=<your opaque value, returned unchanged>
  &code_challenge=<BASE64URL(SHA256(verifier)), unpadded>
  &code_challenge_method=S256
```

After approval, stub redirects the browser to your `redirect_uri` with `code` and `state`. If the customer declines, the redirect contains `error=access_denied` and `state`. It does not contain a code.<br>

{% hint style="info" %}
**Protect authorisation codes with return addresses.** stub matches the scheme and host exactly, and the path as a prefix. Ports must match too, except for `http://localhost` and `http://127.0.0.1`, where any port is accepted so a sandbox can be proved before real endpoints exist. Everything else must be HTTPS. Provide the exact URLs before go-live. Requests with unregistered URLs fail at `/authorize`.
{% endhint %}

#### Get tokens

Exchange the authorisation code from your server. Include the client secret in the request body or an HTTP Basic header. The secret is also required for token refreshes and revocations.

```json
// POST /oauth/token — form encoded
{
  "grant_type":    "authorization_code",
  "code":          "<from the redirect>",
  "client_id":     "<your client id>",
  "client_secret": "<issued by stub, shown once>",
  "code_verifier": "<the verifier for your challenge>",
  "redirect_uri":  "<the same one you sent to /authorize>"
}
```

```json
// Example response
{
  "access_token":  "...",
  "refresh_token": "...",
  "token_type":    "Bearer",
  "expires_in":    3600,
  "scope":         "datasync:push",
  "businessid":    "biz_4f2a..."
}
```

To refresh a token, send `grant_type=refresh_token`, `refresh_token`, `client_id`, and `client_secret`.

{% hint style="info" %}
**Store each new refresh token.** Refresh tokens rotate. Using an older token revokes the token chain. The customer must then reconnect from your online banking portal.
{% endhint %}

The access token identifies one business. Each request applies to that business. Request payloads do not contain a business field.

#### Register accounts

Register the accounts the customer chose to share. Send a stable bank account ID in `externalid`. stub uses this value for matching and returns a `bankaccountid` for each account.

```json
// POST /api/datasync/accounts
// Authorisation: Bearer <access_token>
{
  "accounts": [
    {
      "externalid":    "ACCT-62001234567",
      "accountname":   "Cheque Account",
      "accountnumber": "62001234567",
      "accounttype":   "cheque",
      "bank":          "<your bank's name>",
      "branchcode":    "<your branch code>",
      "currency":      "ZAR"
    }
  ]
}
```

```json
// Example response — 200
{
  "accounts": [
    { "externalid": "ACCT-62001234567", "bankaccountid": "bank_9c21...", "created": true }
  ]
}
```

If a customer already added an account manually or through an aggregator, stub adopts it instead of creating a duplicate. When the `externalid` is one stub has not seen, it matches on the account number — but only against an account that is not already claimed by another `externalid`. An account already registered by another source is left alone. This request is idempotent. Send it again whenever the shared accounts change.

#### Push transactions

Send posted transactions only. stub validates each row on receipt and returns rejected rows in the response.

**Treat any 2xx as accepted.** This endpoint returns **202** when the accounting write is queued, and **200** when it completes while you wait. Which one you get depends on how the environment you are calling is configured, so do not branch on the exact code. A **202** response additionally means the batch is not yet written — use the receipt to find out what became of it.

```json
// POST /api/datasync/transactions
// Authorisation: Bearer <access_token>
// Idempotency-Key: 2026-03-02T09:15Z/ACCT-62001234567/batch-7   (optional, 24h)
{
  "externalid": "ACCT-62001234567",
  "transactions": [
    {
      "externalid":  "TXN-88231",
      "date":        "2026-03-02",
      "value":       -120.50,
      "description": "SPAR RANDBURG",
      "reference":   "CARD PURCHASE",
      "balance":     4300.00,
      "currency":    "ZAR"
    }
  ]
}
```

```json
// Example response — 202
{
  "receipt":  "b41c...",
  "received": 61,
  "accepted": 60,
  "rejected": [ { "externalid": "TXN-88232", "reason": "date must be yyyy-MM-dd" } ]
}
```

Use `GET /api/datasync/receipt/{receipt}` to check a batch status. It returns `accepted`, `ingested`, `declined`, or `failed`. Use it after a timeout when the delivery result is uncertain.

**Resolve rejected rows**

| Reason                                | What to fix                                                              |
| ------------------------------------- | ------------------------------------------------------------------------ |
| `externalid is required`              | Include a stable transaction ID. stub uses it to identify resubmissions. |
| `date must be yyyy-MM-dd`             | Posting date, no time component. Use `datetime` for the timestamp.       |
| `value (or debit/credit) is required` | Send a signed `value`, or a `debit`/`credit` pair.                       |
| `value may not be zero`               | A zero-amount row is not a movement of money.                            |
| `description is required`             | Include the statement narrative shown to the customer.                   |

A rejected row does not fail the batch. stub retains rejected rows for 30 days. An operator can return or replay a corrected row.

{% hint style="warning" %}
**Send at most 5,000 rows per request.** Larger requests return `413` and write nothing. Split daily batches and backfills into smaller requests.
{% endhint %}

#### Handle retries and balances

Duplicate rows do not create duplicate transactions. Repeating a request with the same idempotency key returns the original response.

| Repeat                 | How stub notices                                          | What happens                                                |
| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------------- |
| The same row again     | Its `externalid` already exists                           | stub records it as a duplicate. Nothing is written.         |
| The same request again | Your `Idempotency-Key` matches one from the last 24 hours | stub returns the stored response. The handler does not run. |

An `Idempotency-Key` can contain up to 128 characters. Longer keys return `400`. stub does not truncate keys.

{% hint style="info" %}
**Include the running balance.** It distinguishes valid, similar transactions from duplicates. Without it, a second matching transaction can be rejected as a duplicate.

The balance also checks transaction completeness. A break in the balance chain can indicate a missing transaction.
{% endhint %}

#### End a connection

Either side can end a connection. stub operators cannot end connections.

**Revoke a connection**

```json
// POST /oauth/revoke — RFC 7009, always 200
{
  "token":         "<the refresh token for that business>",
  "client_id":     "<your client id>",
  "client_secret": "<your secret>"
}
```

The endpoint returns `200` for unknown and already-revoked tokens. This prevents token discovery.

**Handle customer revocations**

Customers can disconnect your bank app in stub. stub then sends a signed webhook so you can stop pushing data.

```json
// POST to your unlink webhook (https only)
// X-Stub-Signature: sha256=<HMAC-SHA256 of the exact body, keyed with your newest live secret>
{
  "event":      "link.revoked",
  "appid":      "<your client id>",
  "businessid": "biz_4f2a...",
  "reason":     "customer-uninstalled-app",
  "at":         1789000000000
}
```

Verify the signature with your client secret. No additional shared credential is required. Return `2xx` to acknowledge the webhook. stub retries timeouts and `5xx` responses three times with increasing delays. It does not retry `4xx` responses.

{% hint style="warning" %}
**Access tokens remain valid for their one-hour lifetime.** Transaction writes check the connection's revocation status. Transactions cannot be written after revocation. Account registration can still succeed during that hour.
{% endhint %}

#### Responses

| Code  | Means                                                                      | What to do                                                                                      |
| ----- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `200` | Accounts were written, or a transaction batch was written while you waited | Store the `bankaccountid` values. For transactions, `created` and `duplicates` are in the body. |
| `202` | Transaction batch was accepted and is not yet written                      | Store the `receipt`. Review rejected rows in the body.                                          |
| `400` | Request is malformed or `Idempotency-Key` is too long                      | Check the response body.                                                                        |
| `401` | Token is missing, expired, or invalid                                      | Refresh the token and retry once.                                                               |
| `403` | Customer revoked the connection                                            | Stop pushing for that business. Do not retry.                                                   |
| `413` | Request contains more than 5,000 rows                                      | Split the batch.                                                                                |
| `429` | stub is saturated                                                          | Honour `Retry-After` and back off.                                                              |
| `503` | A stub operator paused data sync                                           | Honour `Retry-After`. Tokens remain valid. Retry later.                                         |

#### Before go-live

1. Provide the exact **return addresses** and **unlink webhook URL**. Both must use HTTPS.
2. Store the **client secret** when it is issued. It is shown once. Two secrets can be active during rotation.
3. Verify the webhook. stub sends a signed test request and records your response.
