OpenDinar API
OpenDinar is the open banking infrastructure for Serbia. It lets your app connect to Serbian bank accounts and read balances, transactions, identity data, and recurring payments — through a single, consistent API.
Instead of building separate integrations with Raiffeisen, Erste, AIK, and others, you integrate once with OpenDinar and get access to all supported banks.
Base URL
https://api.opendinar.com
Supported banks
| Bank | Status | Sandbox data |
|---|---|---|
| Raiffeisen Banka | Sandbox | Accounts, Balance, Transactions, Identity |
| Erste Bank | Sandbox | Accounts, Balance, Transactions |
| AIK Banka | Sandbox | Accounts, Balance, Transactions |
| Banca Intesa | Sandbox | No seeded data yet |
| OTP Banka | Sandbox | No seeded data yet |
| UniCredit Bank Srbija | Sandbox | No seeded data yet |
Live bank connections open once the NBS licence is granted. Until then all six banks report status: "sandbox" from GET /banks.
Authentication
All banking API requests must include your API key in the Authorization header as a Bearer token.
# Every protected request needs this header
Authorization: Bearer od_test_sk_9x2mABC123sandbox
API keys are prefixed to indicate their type:
| Prefix | Type | Use for |
|---|---|---|
od_test_sk_ | Sandbox key | Testing — all data is simulated |
od_live_sk_ | Live key | Coming soon — live keys will be issued once real bank connections launch. Only sandbox keys exist today. |
API keys are issued through the Developer Dashboard — sign-ups open at launch.
Quickstart
Get from zero to fetching transactions in 5 steps.
1. Get your API key
Developer sign-ups open at launch — join the waitlist to be notified. Once sign-ups open: register, verify your email, and your sandbox key will be waiting in the dashboard.
2. Add the Link Widget to your page
<script src="https://api.opendinar.com/v1/opendinar.js"></script>
3. Let a user connect their bank
First create a short-lived link_token from your backend, so your real API key never appears in browser code:
# On your backend
POST /link/token/create
Authorization: Bearer od_test_sk_your_key
Content-Type: application/json
{ "user_id": "user_123" }
# Response — pass link_token to your frontend
{ "link_token": "lt_abc123...", "expires_in": 1800 }
Then initialise the widget with it:
const handler = OpenDinar.create({
linkToken: 'lt_abc123...', // from step above
userId: 'user_123', // your internal user ID
appName: 'Your App Name', // shown to the user in the widget
onSuccess: (publicToken, meta) => {
// Send publicToken to your backend and exchange it
console.log('Connected:', meta.bank_name);
},
onExit: () => console.log('User closed the widget'),
});
document.getElementById('connect-btn').onclick = () => handler.open();
apiKey directly instead of linkToken — but never do that in production code.
4. Exchange the public token on your backend
# Exchange the one-time public_token for a permanent connection_id
POST /connect/exchange
Authorization: Bearer od_test_sk_your_key
Content-Type: application/json
{ "public_token": "public_abc123..." }
# Response
{
"connection_id": "conn_xyz...", // store this in your database
"bank_id": "raiffeisen",
"status": "active"
}
5. Fetch accounts and transactions
# List accounts — returns the account IDs for this connection
GET /accounts
Authorization: Bearer od_test_sk_your_key
# Get transactions for an account (use an id from the response above)
GET /accounts/acc_1a2b3c4d5e6f7a8b/transactions?limit=50
Authorization: Bearer od_test_sk_your_key
Environments
| Environment | Key prefix | Data | Billed |
|---|---|---|---|
| Sandbox | od_test_sk_ |
Fake — pre-seeded accounts, transactions | No |
| Live (coming soon) | od_live_sk_ |
Real bank connections — opens after the NBS licence is granted | Yes |
Versioning
Every banking endpoint also works under a /v1 prefix — GET /v1/accounts behaves exactly like GET /accounts. Every response carries an X-OpenDinar-Version: v1 header.
GET /v1/accounts
Authorization: Bearer od_test_sk_your_key
# Response header
X-OpenDinar-Version: v1
/v1 prefix in new integrations. When a future /v2 ships with breaking changes, apps pinned to /v1 will keep working unchanged.
Rate Limits
| Scope | Limit | Counted per |
|---|---|---|
Banking API (/banks, /connect, /connections, /accounts, /transactions, /link, /sandbox) | 300 requests / 15 min | API key (or IP if no key) |
/developers/login and /developers/register | 10 attempts / 15 min | IP address |
Every rate-limited response includes standard RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers so you can pace your requests. Exceeding a limit returns HTTP 429 with error code RATE_LIMIT_EXCEEDED.
Error Handling
Every error response has the same shape — always check error_code rather than HTTP status codes for handling logic.
{
"error_type": "AUTH_ERROR",
"error_code": "INVALID_API_KEY",
"error_message": "The API key provided is not valid.",
"docs": "https://api.opendinar.com/docs/#error-codes",
"request_id": "a3f9c2d1-..."
}
Error types
| error_type | Meaning |
|---|---|
| AUTH_ERROR | Missing, invalid, expired, or revoked API key |
| INVALID_REQUEST | Malformed request, missing required fields, bad input |
| ITEM_ERROR | Bank, account, session, or connection issue |
| RATE_LIMIT_ERROR | Too many requests — slow down and retry later |
| API_ERROR | Server-side error — safe to retry |
See the full error code reference below.
Link Widget
The Link Widget is a drop-in JavaScript UI that handles the entire bank connection experience. It shows a list of supported banks, opens a secure authorization popup, and returns a public_token when the user connects successfully.
You never handle bank credentials or OAuth flows yourself — the widget handles everything.
Installation
Include the widget script on any page where users need to connect a bank:
<script src="https://api.opendinar.com/v1/opendinar.js"></script>
The script is lightweight (~30 KB), self-contained, and has no dependencies.
Configuration
Create a handler using OpenDinar.create(config):
const handler = OpenDinar.create({
linkToken: 'lt_...', // recommended — from POST /link/token/create
userId: 'user_123', // optional — your internal user ID
appName: 'Your App Name', // optional — shown to the user
onSuccess: (publicToken, meta) => { }, // called on successful connection
onExit: () => { }, // called when user closes widget
});
Config options
| Option | Type | Description | |
|---|---|---|---|
| linkToken | string | recommended | Short-lived lt_… token from POST /link/token/create — keeps your API key on your server. Provide either linkToken or apiKey. |
| apiKey | string | optional | Your sandbox API key. Fine for quick sandbox tests; use linkToken in production so the key never appears in browser code. |
| userId | string | optional | Your internal user identifier. Auto-generated if omitted. |
| appName | string | optional | Your app's name, shown on the widget's intro screen ("Your App uses OpenDinar to link your bank"). Defaults to "This app". |
| onSuccess | function | optional | Called with (publicToken, { bank_id, bank_name, status }) when user connects |
| onExit | function | optional | Called when user closes the widget without connecting |
The widget's intro screen also shows a privacy consent checkbox — the user must tick "I have read and agree to the Privacy Policy" before they can continue. This is built in; no configuration needed.
Handler methods
| Method | Description |
|---|---|
| handler.open() | Open the widget modal |
| handler.close() | Close the widget modal programmatically |
Token Flow
The connection process uses a 3-step token exchange — the same pattern as Plaid and Stripe Connect.
session_tokenpublic_tokenconnection_id- Widget calls
POST /connect— creates a 10-minutesession_tokenand opens the bank's authorization page - User authorizes — bank confirms access and the session is marked
authorizedwith a 5-minutepublic_token - Your backend calls
POST /connect/exchange— trades the one-timepublic_tokenfor a permanentconnection_id
public_token on your backend, not client-side. It expires in 5 minutes and can only be used once.
Banks
These endpoints are public — no API key required.
List banks
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| search | string | optional | Filter by bank name (e.g. ?search=erste) |
| status | string | optional | live, sandbox, or coming_soon |
| limit | integer | optional | Max results (default 50) |
| offset | integer | optional | Pagination offset |
Get one bank
# Example response
{
"id": "raiffeisen",
"name": "Raiffeisen Banka",
"country": "RS",
"bic": "RZBSRSBG",
"status": "sandbox",
"color": "#FFE500",
"market_share_pct": 11.19,
"feat_accounts": 1,
"feat_transactions": 1,
"feat_balance": 1,
"feat_identity": 1
}
Connect
These endpoints power the 3-step bank authorization flow used by the Link Widget.
Create link token
Creates a short-lived lt_… token to initialise the Link Widget with, so your real API key never appears in frontend code. Valid for 30 minutes.
Request body
| Field | Type | Description | |
|---|---|---|---|
| user_id | string | optional | Your internal identifier for this user |
# Response
{
"object": "link_token",
"link_token": "lt_abc123...",
"expires_in": 1800,
"expires_at": "2026-08-25T10:30:00.000Z"
}
Start authorization
Creates a 10-minute session and returns a URL to open for bank authorization.
Request body
| Field | Type | Description | |
|---|---|---|---|
| bank_id | string | required | Bank identifier (e.g. raiffeisen) |
| user_id | string | required | Your internal identifier for this user |
| redirect_url | string | required | Where to send the user after authorization |
Check status
Poll this endpoint to check if the user has completed authorization. When status is authorized, a public_token is included.
# Pending response
{ "status": "pending" }
# Authorized response
{
"status": "authorized",
"public_token": "public_abc123...",
"expires_in": 300
}
Exchange public token
Exchanges the one-time public_token for a permanent connection_id. Must be called from your backend.
Request body
| Field | Type | Description | |
|---|---|---|---|
| public_token | string | required | The token from the onSuccess callback |
public_token can only be used once and expires in 5 minutes.
Connections
A connection is the permanent authorized link between a user and a bank. Store the connection_id in your database — it's what ties a user to their bank data.
List connections
Returns all connections made through your API key, including each connection's consent status:
{
"connection_id": "conn_abc123...",
"bank_id": "raiffeisen",
"user_id": "user_123",
"status": "active", // active | expired | revoked
"connected_at": "2026-08-01T09:00:00.000Z",
"expires_at": "2026-10-30T09:00:00.000Z",
"days_until_expiry": 66
}
Revoke a connection
connections:write
Permanently revokes a user's bank connection and erases its personal data — the connection's accounts, transactions, and consent record are deleted. Use this for GDPR/ZZPL "right to erasure" requests. The user will need to re-authorize to reconnect.
Keys created through the Developer Dashboard have the connections:write scope. The shared public demo keys are read-only and cannot delete anything — calling this with one returns 403 INSUFFICIENT_PERMISSIONS.
ITEM.CONNECTION_REVOKED webhook if registered.
Export a connection's data (right of access)
Returns everything OpenDinar holds for one connection — accounts, transactions, and the consent record. Call this on behalf of your end user when they make a GDPR/ZZPL "right of access" request.
Consent & Expiry
A user's consent to share their bank data does not last forever. Every connection expires 90 days after it is created — the standard open-banking consent period.
- Data requests on an expired connection return HTTP
401with error codeCONSENT_EXPIREDand hints on how to reconnect. - Re-connecting the same user to the same bank renews the existing connection in place — the
connection_idstays the same, no duplicate is created. - A daily scan fires the
ITEM.CONNECTION_EXPIRINGwebhook when a connection is within 7 days of expiry, andITEM.CONNECTION_EXPIREDonce it lapses — so your app can prompt the user to reconnect in time.
# Request on an expired connection
GET /accounts/acc_1a2b.../balance
# 401 response
{
"error_type": "ITEM_ERROR",
"error_code": "CONSENT_EXPIRED",
"error_message": "The user's consent for this bank connection has expired...",
"docs": "https://api.opendinar.com/docs/#error-codes",
"request_id": "a3f9c2d1-..."
}
Check expires_at and days_until_expiry on GET /connections to see where each connection stands.
Accounts
List accounts
accounts:read
Returns all bank accounts across all active connections for this API key. Accounts are isolated — you only see accounts connected through your key.
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| bank_id | string | optional | Filter to one bank (e.g. ?bank_id=raiffeisen) |
Get balance
balance:read
{
"object": "balance",
"account_id": "acc_1a2b3c4d5e6f7a8b",
"currency": "RSD",
"balance": 185420.50,
"available_balance": 183200.00,
"retrieved_at": "2026-04-04T10:30:00.000Z"
}
Get identity
accounts:read
Returns the account holder's personal details. Only available if the bank supports identity data (feat_identity = true). Currently supported by Raiffeisen.
Get auth (account verification)
accounts:read
Returns the account's verified IBAN, BIC, and holder name — for confirming account ownership before, say, paying out a salary or a refund. OpenDinar never moves money itself; this endpoint only verifies account details.
{
"object": "auth",
"account_id": "acc_1a2b3c4d5e6f7a8b",
"bank_id": "raiffeisen",
"bank_name": "Raiffeisen Banka",
"numbers": {
"iban": "RS35265100000012345678",
"bic": "RZBSRSBG",
"currency": "RSD"
},
"account_holder": {
"name": "Marko Kovačević",
"verified": true
},
"account_type": "current",
"retrieved_at": "2026-08-25T10:30:00.000Z"
}
Returns AUTH_UNAVAILABLE (404) if the bank doesn't expose account data or the account has no IBAN.
Force refresh
accounts:read
Triggers a fresh data pull from the bank and resets the sync cursor. Fires the TRANSACTIONS.SYNC_UPDATES_AVAILABLE webhook.
Transactions
Get transactions
transactions:read
Query parameters
| Parameter | Type | Description | |
|---|---|---|---|
| from | date | optional | Start date YYYY-MM-DD |
| to | date | optional | End date YYYY-MM-DD |
| category | string | optional | groceries, rent, dining, utilities, transport, entertainment, subscriptions, salary, transfer |
| limit | integer | optional | Max results (default 50, max 200) |
| cursor | string | optional | Enable sync mode — returns only new transactions since this cursor |
Transaction object
{
"id": "txn_5c4d3e2f1a0b9c8d",
"account_id": "acc_1a2b3c4d5e6f7a8b",
"date": "2026-04-04",
"amount": -1650.00,
"currency": "RSD",
"description": "DIS market Vracar",
"category": "groceries",
"type": "debit",
"pending": 1, // 1 = not yet posted, 0 = cleared
"pending_transaction_id": null // links pending → posted once cleared
}
Cursor-based sync
Use ?cursor= to efficiently fetch only new transactions since your last call — like Plaid's /transactions/sync.
# First call — no cursor, returns full history
GET /accounts/acc_1a2b3c4d5e6f7a8b/transactions?cursor=
# Response includes next_cursor
{
"sync_status": "HISTORICAL_UPDATE_COMPLETE",
"added": [ /* transactions */ ],
"next_cursor": "txn_9f8e7d6c5b4a3f2e"
}
# Next call — only returns NEW transactions since last time
GET /accounts/acc_1a2b3c4d5e6f7a8b/transactions?cursor=txn_9f8e7d6c5b4a3f2e
Recurring Transactions
transactions:read
Detects recurring payments and income from transaction history. Groups transactions by description and category, identifies patterns, and returns outgoing (subscriptions, rent) and incoming (salary) streams separately.
{
"outgoing": [
{
"description": "Netflix",
"category": "subscriptions",
"frequency": "monthly",
"amount_type": "fixed",
"typical_amount": 790.00,
"occurrences": 3
}
],
"incoming": [
{
"description": "Plata - januar 2026",
"category": "salary",
"frequency": "monthly",
"typical_amount": 95000.00
}
],
"monthly_outgoing_total": 3240.00,
"monthly_incoming_total": 95000.00
}
Webhooks
Instead of polling the API for changes, register a webhook URL and OpenDinar will send you an HTTP POST whenever something happens — a bank connects, a connection is revoked, or new transactions are ready.
Register webhooks from the Developer Dashboard or via the Webhooks API.
Webhook Events
AUTH.AUTOMATICALLY_VERIFIED
Fired when a user successfully connects a bank through the Link Widget.
{
"webhook_type": "AUTH",
"webhook_code": "AUTOMATICALLY_VERIFIED",
"connection_id": "conn_abc123",
"bank_id": "raiffeisen",
"user_id": "user_123",
"timestamp": 1743760200
}
ITEM.CONNECTION_REVOKED
Fired when a connection is revoked (either by the user or via DELETE /connections/:id).
{
"webhook_type": "ITEM",
"webhook_code": "CONNECTION_REVOKED",
"connection_id": "conn_abc123",
"bank_id": "raiffeisen",
"user_id": "user_123",
"timestamp": 1743760200
}
ITEM.CONNECTION_EXPIRING
Fired once when a connection's 90-day consent is within 7 days of expiring — prompt your user to reconnect. See Consent & Expiry.
{
"webhook_type": "ITEM",
"webhook_code": "CONNECTION_EXPIRING",
"connection_id": "conn_abc123",
"bank_id": "raiffeisen",
"user_id": "user_123",
"expires_at": "2026-09-01T09:00:00.000Z",
"days_until_expiry": 5,
"timestamp": 1743760200
}
ITEM.CONNECTION_EXPIRED
Fired once when a connection's consent has lapsed. Data requests on it now return CONSENT_EXPIRED until the user reconnects.
{
"webhook_type": "ITEM",
"webhook_code": "CONNECTION_EXPIRED",
"connection_id": "conn_abc123",
"bank_id": "raiffeisen",
"user_id": "user_123",
"expired_at": "2026-09-01T09:00:00.000Z",
"timestamp": 1743760200
}
TRANSACTIONS.SYNC_UPDATES_AVAILABLE
Fired when new transactions are available for an account (after a force sync via POST /accounts/:id/sync, or when the bank reports new data).
{
"webhook_type": "TRANSACTIONS",
"webhook_code": "SYNC_UPDATES_AVAILABLE",
"account_id": "acc_1a2b3c4d5e6f7a8b",
"bank_id": "raiffeisen",
"timestamp": 1743760200
}
Verifying Webhooks
Every webhook request includes an OpenDinar-Signature header. Always verify this before processing the event.
# Header format (same as Stripe)
OpenDinar-Signature: t=1743760200,v1=abc123...
Verification steps
- Extract
t(timestamp) andv1(signature) from the header - Construct the signed string:
t + "." + raw_request_body - Compute
HMAC-SHA256of that string using your webhook secret - Compare your result to
v1— reject if they don't match - Reject if the timestamp is more than 5 minutes old (prevents replay attacks)
// Node.js verification example
const { createHmac } = require('crypto');
function verifyWebhook(secret, rawBody, signatureHeader) {
const parts = Object.fromEntries(signatureHeader.split(',').map(p => p.split('=')));
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return expected === parts.v1;
}
Developer Portal — Auth
These endpoints let developers manage their accounts programmatically. All management endpoints require a developer session token (devses_…) obtained from POST /developers/login.
Register
Creates an account and sends a verification email. You must verify your email before you can log in — the API key is shown in the dashboard after verification, not in the register response.
Login
Returns a devses_… session token valid for 24 hours. Unverified accounts get an EMAIL_NOT_VERIFIED error.
Logout
Get account
Email verification
Resend takes just { "email": "…" } — no login needed.
Password reset
Forgot-password emails a one-time reset link; reset-password takes the token from that link plus the new password.
Developer Portal — API Keys
List keys
Returns key prefix, app name, environment, and status. Full keys are never shown after creation.
Generate a key
Revoke a key
Takes effect immediately. Any requests using the revoked key will receive a 401 REVOKED_API_KEY error.
Developer Portal — Usage
Returns total request counts, breakdown by endpoint, error rates, and recent errors for all your API keys.
Developer Portal — Webhooks
Register a webhook
Request body
| Field | Type | Description | |
|---|---|---|---|
| url | string | required | HTTPS URL — cannot be localhost or a private IP |
| events | array | optional | Event types to receive. Defaults to all events if omitted. |
List webhooks
Remove a webhook
Developer Portal — Your Data (GDPR/ZZPL)
Every developer account has full data-protection rights, exercisable via the API:
See what we hold (right of access)
Download everything (right to portability)
Returns all your account data as a JSON download.
Delete your account (right to erasure)
Permanently deletes your account, keys, webhooks, and connections, and anonymises request logs. This cannot be undone.
Error Codes
| error_code | Type | HTTP | Description |
|---|---|---|---|
| MISSING_API_KEY | AUTH_ERROR | 401 | No Authorization header provided |
| INVALID_LINK_TOKEN | AUTH_ERROR | 401 | Link token not recognised |
| EXPIRED_LINK_TOKEN | AUTH_ERROR | 401 | Link token older than 30 minutes — create a new one |
| INVALID_CREDENTIALS | AUTH_ERROR | 401 | Email or password is incorrect |
| EMAIL_NOT_VERIFIED | AUTH_ERROR | 403 | Verify your email before logging in |
| WRONG_TOKEN_TYPE | AUTH_ERROR | 401 | Endpoint needs a developer session token, not an API key |
| DEVELOPER_NOT_FOUND | AUTH_ERROR | 401 | Session refers to an account that no longer exists |
| INVALID_PASSWORD | AUTH_ERROR | 401 | Password confirmation failed (account deletion) |
| INVALID_ADMIN_KEY | AUTH_ERROR | 401 | Admin endpoints — owner only |
| INVALID_API_KEY | AUTH_ERROR | 401 | API key is not recognised |
| REVOKED_API_KEY | AUTH_ERROR | 401 | API key has been revoked |
| MISSING_DEV_TOKEN | AUTH_ERROR | 401 | Developer session token not provided |
| INVALID_DEV_TOKEN | AUTH_ERROR | 401 | Session token is invalid |
| EXPIRED_DEV_TOKEN | AUTH_ERROR | 401 | Session expired — login again |
| INSUFFICIENT_PERMISSIONS | AUTH_ERROR | 403 | API key lacks required permission scope |
| MISSING_FIELDS | INVALID_REQUEST | 400 | Required request fields are missing |
| INVALID_EMAIL | INVALID_REQUEST | 400 | Email address format is invalid |
| PASSWORD_TOO_SHORT | INVALID_REQUEST | 400 | Password must be at least 8 characters |
| INVALID_CURSOR | INVALID_REQUEST | 400 | Cursor value not recognised — omit to restart sync |
| INVALID_ENVIRONMENT | INVALID_REQUEST | 400 | environment must be sandbox or live |
| MISSING_APP_NAME | INVALID_REQUEST | 400 | app_name is required when creating a key |
| MISSING_PUBLIC_TOKEN | INVALID_REQUEST | 400 | public_token is required for the exchange |
| MISSING_SESSION_TOKEN | INVALID_REQUEST | 400 | session_token is required (sandbox authorize) |
| CONSENT_REQUIRED | INVALID_REQUEST | 400 | Privacy Policy + Terms must be accepted at registration |
| NAME_REQUIRED | INVALID_REQUEST | 400 | Name is missing (waitlist) |
| INPUT_TOO_LONG | INVALID_REQUEST | 400 | A field exceeds its maximum length |
| PASSWORD_REQUIRED | INVALID_REQUEST | 400 | Password confirmation is required for this action |
| MISSING_TOKEN | INVALID_REQUEST | 400 | Verification token is missing |
| INVALID_RESET_TOKEN | INVALID_REQUEST | 400 | Password-reset link is invalid or already used |
| EXPIRED_RESET_TOKEN | INVALID_REQUEST | 400 | Password-reset link has expired — request a new one |
| INVALID_WEBHOOK_URL | INVALID_REQUEST | 400 | Webhook URL must be a public HTTPS address |
| INVALID_EVENTS | INVALID_REQUEST | 400 | events must be an array of supported event names |
| NOT_FOUND | INVALID_REQUEST | 404 | Route does not exist |
| EMAIL_TAKEN | INVALID_REQUEST | 409 | Email already registered |
| BANK_NOT_FOUND | ITEM_ERROR | 404 | Bank ID not recognised |
| BANK_NOT_AVAILABLE | ITEM_ERROR | 400 | Bank is listed as coming_soon — not yet connectable |
| SESSION_NOT_FOUND | ITEM_ERROR | 404 | Session token not found |
| SESSION_EXPIRED | ITEM_ERROR | 410 | 10-minute session has expired |
| SESSION_NOT_PENDING | ITEM_ERROR | 400 | Session was already authorized or has expired |
| INVALID_PUBLIC_TOKEN | ITEM_ERROR | 404 | Public token not found |
| PUBLIC_TOKEN_ALREADY_USED | ITEM_ERROR | 410 | Public token was already exchanged |
| PUBLIC_TOKEN_EXPIRED | ITEM_ERROR | 410 | 5-minute public token window has passed |
| ACCOUNT_NOT_FOUND | ITEM_ERROR | 404 | Account doesn't exist or doesn't belong to this key |
| IDENTITY_UNAVAILABLE | ITEM_ERROR | 404 | Bank doesn't support identity data |
| AUTH_UNAVAILABLE | ITEM_ERROR | 404 | Bank doesn't support account verification, or the account has no IBAN |
| CONNECTION_NOT_FOUND | ITEM_ERROR | 404 | Connection doesn't exist or doesn't belong to this key |
| CONNECTION_ALREADY_REVOKED | ITEM_ERROR | 409 | Connection was already revoked |
| CONSENT_EXPIRED | ITEM_ERROR | 401 | The user's 90-day consent has lapsed — reconnect them |
| KEY_NOT_FOUND | ITEM_ERROR | 404 | API key does not exist or is not yours |
| KEY_ALREADY_REVOKED | ITEM_ERROR | 409 | API key was already revoked |
| WEBHOOK_NOT_FOUND | ITEM_ERROR | 404 | Webhook does not exist or is not yours |
| RATE_LIMIT_EXCEEDED | RATE_LIMIT_ERROR | 429 | Too many requests — check the RateLimit-* headers and retry later |
| INTERNAL_ERROR | API_ERROR | 500 | Server error — safe to retry with exponential backoff |
Sandbox
The sandbox environment is fully functional with pre-seeded fake data. No real bank connections or real user data is involved.
Test API keys
| Key | App |
|---|---|
od_test_sk_9x2mABC123sandbox | Mani App |
od_test_sk_demo456developer | Demo Developer |
These shared keys are read-only: they can browse sandbox data and connect banks, but cannot delete or modify anything. For the full experience (including revoking connections), use your own key.
Sandbox dataset
Each new connection gets its own private copy of the bank's test accounts and transactions, with freshly generated IDs (acc_… / txn_…). Two connections never share data — just like in production. Call GET /accounts after connecting to get your account IDs.
| Bank | Test accounts you get per connection |
|---|---|
| Raiffeisen | RSD current account + EUR savings account, with transaction history |
| Erste Bank | RSD current account, with transaction history |
| AIK Banka | RSD current account, with transaction history |
| Banca Intesa, OTP, UniCredit | No seeded data yet — connecting returns no accounts |
Sandbox auth flow
When connecting a bank in sandbox, the Link Widget opens a fake bank login page at /sandbox/auth.html. Enter any username and password — click Odobri pristup to authorize. The widget picks it up automatically.
Sandbox-only endpoint
Called by the sandbox auth page to programmatically authorize a session. Pass { "session_token": "sess_..." }. Useful for automated testing.