Connecting a client through OAuth 2.0
Overview
The partner needs access to the accounts and payments of a specific client, acting on that client's behalf. The client grants that access explicitly on the bank's consent page.
About OAuth 2.0.
This is an authorization protocol — it lets the client grant the partner the right to act on their behalf without ever sharing their login and password. The client decides which set of permissions (scopes) to grant.
Verification comes first.
Before granting access, the client must be verified — KYC for an individual, KYB for a company. Verification happens entirely in the bank's interface, before any interaction with the partner.
Precondition — client verification before OAuth
By the time a client first reaches the partner's application and grants access, verification is already complete and the first virtual account already exists.
Path 1 — an individual connects a personal account
- The client registers in the bank UI.
- The client passes KYC — on success the system automatically creates their first virtual account.
- The client opens the partner's application and initiates the grant — the partner redirects them to the bank to confirm consent.
- After confirmation the partner receives a JWT bound to the client's personal account:
sub = userId, nocompanyId. - Every subsequent partner request runs on behalf of this client and touches only their personal accounts and payments.
Path 2 — a client registers a company and passes KYB
A company does not exist independently of a person — it is created by a client, who becomes its registrant.
- The client registers and passes KYC.
- The client creates a company and automatically becomes its registrant.
- The registrant completes KYB for the company.
- Once KYB is approved, a virtual account is created for the company automatically.
- The registrant initiates the grant in the partner's application and is redirected to the consent page.
- On the consent page the client chooses which account the grant applies to — their personal one or one of their companies. If a company is chosen, the JWT carries
sub = userIdof the registrant and acompanyId. - Every subsequent request runs for the company on behalf of the registrant.
Path 3 — connecting company employees
- Every new employee registers and passes KYC.
- The registrant adds employees, specifying a role and a share for each.
- Each employee can then grant access to the company independently, going through the OAuth flow separately.
- The resulting JWT carries
sub = userIdof the employee and the company'scompanyId.
JWT structure
Every JWT is issued for a specific user, never for a company. The key difference is whether companyId is present.
| Scenario | sub | companyId |
|---|---|---|
| Personal account | client's userId | absent |
| Company account | client's userId | present |
If companyId is present, all operations run in the company context on behalf of the user who authorized.
Fetch the deeplink
/v1/oauth/deeplinkAuth: None — public endpoint
Fetch the base authorization URL instead of hard-coding it, so the same integration works across environments.
/v1/oauth/deeplinkcurl 'https://sandbox-bank.tedr.com/open-banking/v1/oauth/deeplink'
const response = await fetch('https://sandbox-bank.tedr.com/open-banking/v1/oauth/deeplink');
const { data } = await response.json();
const deeplink = data.url;
import requests
response = requests.get('https://sandbox-bank.tedr.com/open-banking/v1/oauth/deeplink')
deeplink = response.json()['data']['url']
{
"status": "SUCCESS",
"data": { "url": "https://dev-tbank.tedr.com/oauth/" }
}
Discover the available scopes (optional)
/v1/oauth/scopesAuth: None — public endpoint
Query parameters: ids[], names[], scopes[], descriptions[], categories[], pagination[limit], pagination[skip].
/v1/oauth/scopescurl -G 'https://sandbox-bank.tedr.com/open-banking/v1/oauth/scopes' \
-d 'pagination[skip]=0' \
-d 'pagination[limit]=20'
const query = new URLSearchParams({
'pagination[skip]': '0',
'pagination[limit]': '20',
});
const response = await fetch(`https://sandbox-bank.tedr.com/open-banking/v1/oauth/scopes?${query}`);
const { data } = await response.json();
import requests
response = requests.get(
'https://sandbox-bank.tedr.com/open-banking/v1/oauth/scopes',
params={'pagination[skip]': 0, 'pagination[limit]': 20},
)
scopes = response.json()['data']['nodes']
{
"status": "SUCCESS",
"data": {
"nodes": [
{
"id": "cc2d0454-0a68-4443-b71c-14e2b60ddd2e",
"createdAt": "2026-01-15T10:00:00.000Z",
"updatedAt": "2026-01-15T10:00:00.000Z",
"name": "Read accounts",
"description": "Grants read-only access to accounts.",
"scope": "tb:accounts:read",
"category": "INDIVIDUAL"
}
],
"count": 1,
"cursor": 1
}
}
category is INDIVIDUAL or CORPORATE.
It marks which kind of account the scope applies to. It is not a grouping by module.
Scopes currently used by the API
| Scope | Grants |
|---|---|
tb:accounts:read | Read accounts, balances and bank details. |
tb:accounts:write | Create accounts. |
tb:payments:read | Read payments. |
tb:payments:write | Create and send payments. |
tb:tariffs:read | Read tariffs, limits and fee calculations. |
tb:personal-data:read | Read the client's personal data. |
tb:internal-cards:read | Read cards. |
tb:internal-cards:write | Order and manage cards. |
tb:external-cards:read | Read Stripe cards and Stripe payment events. |
Build the authorization URL and redirect the client
| Parameter | Required | Description |
|---|---|---|
responseType | yes | Must be code. |
clientId | yes | The partner client id issued during onboarding. |
redirectUri | yes | Where the client is sent back after consent. |
scopes | yes | Comma-separated list of the scopes being requested. |
state | yes | An arbitrary string the partner generates. It is returned unchanged on the redirect — verify it to protect against CSRF. |
accountCategory | no | Limits what the consent page offers: INDIVIDUAL — personal accounts only, CORPORATE — corporate accounts only, omitted — the client chooses. |
An invalid clientId or redirectUri gives no redirect back.
The sender cannot be identified, so the client is never returned to the partner.
URL template
{deeplink}?responseType=code
&clientId={PARTNER_CLIENT_ID}
&redirectUri={CALLBACK_URL}
&scopes={COMMA_SEPARATED_SCOPES}
&state={ARBITRARY_STRING}
&accountCategory={INDIVIDUAL|CORPORATE} // optional
Example
https://dev-tbank.tedr.com/oauth/?responseType=code
&clientId=<your-client-id>
&redirectUri=https://partner-app.com/oauth/callback
&scopes=tb:accounts:read,tb:accounts:write,tb:payments:read,tb:payments:write
&state=random-csrf-token-xyz
&accountCategory=CORPORATE
The consent page
If the client is not signed in, they authenticate first, then land on the consent page. The consent page lists the requested scopes and the partner's logo.
On "Allow" the client is redirected back to the redirectUri with state and code. The authorization code is a UUID and is valid for 60 seconds.
Successful redirect
https://partner-app.com/oauth/callback?state=random-csrf-token-xyz&code=<authorization-code>
Errors returned on the redirect
| Error in the URL | Reason |
|---|---|
access_denied | The client pressed "Deny". |
unsupported_response_type | responseType was not code. |
invalid_scope | A requested scope does not exist. |
Error redirects
https://partner-app.com/oauth/callback?state=string&error=access_denied&error_description=The+user+refused+authorization
https://partner-app.com/oauth/callback?state=string&error=unsupported_response_type&error_description=The+response_type+is+invalid
https://partner-app.com/oauth/callback?state=string&error=invalid_scope&error_description=Invalid+scope+specified.
Exchange the code for an access token
/v1/oauth/access-tokenAuth: None — public endpoint
The code is valid for exactly 1 minute. Perform the exchange immediately after receiving the redirect.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| grantType | string | Required | Always authorization_code. | |
| code | string | Required | The authorization code from the redirect. | |
| redirectUri | string | Required | Must match the one used in the authorization URL. | |
| clientId | string | Required | The partner client id. | |
| clientSecret | string | Required | The partner client secret. Keep it server-side. |
/v1/oauth/access-tokencurl -X POST 'https://sandbox-bank.tedr.com/open-banking/v1/oauth/access-token' \
-H 'Content-Type: application/json' \
-d '{
"grantType": "authorization_code",
"code": "<authorization-code>",
"redirectUri": "https://partner-app.com/oauth/callback",
"clientId": "<your-client-id>",
"clientSecret": "<your-client-secret>"
}'
const response = await fetch('https://sandbox-bank.tedr.com/open-banking/v1/oauth/access-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grantType: 'authorization_code',
code: '<authorization-code>',
redirectUri: 'https://partner-app.com/oauth/callback',
clientId: '<your-client-id>',
clientSecret: '<your-client-secret>',
}),
});
const { data } = await response.json();
const { accessToken, expiresIn } = data;
import requests
response = requests.post(
'https://sandbox-bank.tedr.com/open-banking/v1/oauth/access-token',
json={
'grantType': 'authorization_code',
'code': '<authorization-code>',
'redirectUri': 'https://partner-app.com/oauth/callback',
'clientId': '<your-client-id>',
'clientSecret': '<your-client-secret>',
},
)
access_token = response.json()['data']['accessToken']
{
"status": "SUCCESS",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 31536000
}
}
Every failure returns the same code.
An expired code, a reused code, a mismatched redirectUri or a wrong clientSecret all return OPEN-BANKING-029, without naming the specific cause. This prevents probing for the reason of a rejection.
Decoded token payload
{
"externalServiceId": "<your-client-id>",
"scopes": ["tb:accounts:read", "tb:payments:write"],
"sub": "c9788fca-ed84-49e9-a511-81af1906c393",
"jti": "2b6df3c0-6cfb-4a57-ad61-86ab371384a3",
"iss": "<issuer>",
"iat": 1739981383,
"exp": 1771517383
}
Error response
{
"status": "ERROR",
"errors": [{
"title": "OAuth invalid payload for exchange code",
"code": "OPEN-BANKING-029",
"message": "Invalid payload was provided in exchange code for token."
}]
}
Using the access token
Every request to a client-scoped endpoint carries the bearer token.
The token identifies the client (sub), the partner (externalServiceId) and the granted scopes. Requests are additionally checked against access control; a rejected request returns 403.
Webhook endpoints authenticate differently.
The webhook management endpoints (Webhooks) use x-client-id and x-api-key instead of the bearer token, because subscriptions belong to the partner service, not to a client.
Authorization header
Authorization: Bearer <access-token>
