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 | Features |
|---|---|---|
| Raiffeisen Bank | Live | Accounts, Balance, Transactions, Identity |
| Erste Bank | Live | Accounts, Balance, Transactions |
| AIK Banka | Sandbox | Accounts, Balance, Transactions |
| Banca Intesa | Coming soon | — |
| OTP Banka | Coming soon | — |
| UniCredit | Coming soon | — |
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 fake |
od_live_sk_ | Live key | Production — real bank connections |
Get your API key from the Developer Dashboard.
Quickstart
Get from zero to fetching transactions in 4 steps.
1. Get your API key
Register at the Developer Dashboard — you'll get a sandbox key immediately.
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
const handler = OpenDinar.create({
apiKey: 'od_test_sk_your_key_here',
userId: 'user_123', // your internal user ID
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();
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
GET /accounts
Authorization: Bearer od_test_sk_your_key
# Get transactions for an account
GET /accounts/acc_rai_001/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 | od_live_sk_ |
Real bank connections and real user data | Yes |
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#invalid_api_key",
"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 |
| 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 (~15kb), self-contained, and has no dependencies.
Configuration
Create a handler using OpenDinar.create(config):
const handler = OpenDinar.create({
apiKey: 'od_test_sk_...', // required
userId: 'user_123', // optional — your internal user ID
onSuccess: (publicToken, meta) => { }, // called on successful connection
onExit: () => { }, // called when user closes widget
});
Config options
| Option | Type | Description | |
|---|---|---|---|
| apiKey | string | required | Your sandbox or live API key |
| userId | string | optional | Your internal user identifier. Auto-generated if omitted. |
| 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 |
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 Bank",
"country": "RS",
"bic": "RZBSRSBG",
"status": "live",
"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.
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.
Revoke a connection
Permanently revokes a user's bank connection. Required for GDPR "right to be forgotten" requests. The user will need to re-authorize to reconnect.
ITEM.CONNECTION_REVOKED webhook if registered.
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_rai_001",
"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.
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_001",
"account_id": "acc_rai_001",
"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_rai_001/transactions?cursor=
# Response includes next_cursor
{
"sync_status": "HISTORICAL_UPDATE_COMPLETE",
"added": [ /* transactions */ ],
"next_cursor": "txn_rai_014"
}
# Next call — only returns NEW transactions since last time
GET /accounts/acc_rai_001/transactions?cursor=txn_rai_014
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
}
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_rai_001",
"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
Login
Returns a devses_… session token valid for 24 hours.
Logout
Get account
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
Error Codes
| error_code | Type | HTTP | Description |
|---|---|---|---|
| MISSING_API_KEY | AUTH_ERROR | 401 | No Authorization header provided |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
Pre-seeded accounts
| Account ID | Bank | Currency | Type |
|---|---|---|---|
acc_rai_001 | Raiffeisen | RSD | Current |
acc_rai_002 | Raiffeisen | EUR | Savings |
acc_ers_001 | Erste Bank | RSD | Current |
acc_aik_001 | AIK Banka | RSD | Current |
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.