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

BankStatusFeatures
Raiffeisen BankLiveAccounts, Balance, Transactions, Identity
Erste BankLiveAccounts, Balance, Transactions
AIK BankaSandboxAccounts, Balance, Transactions
Banca IntesaComing soon
OTP BankaComing soon
UniCreditComing 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:

PrefixTypeUse for
od_test_sk_Sandbox keyTesting — all data is fake
od_live_sk_Live keyProduction — real bank connections
Keep your key secret. Never expose it in client-side JavaScript or commit it to version control. Use environment variables.

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

EnvironmentKey prefixDataBilled
Sandbox od_test_sk_ Fake — pre-seeded accounts, transactions No
Live od_live_sk_ Real bank connections and real user data Yes
All sandbox requests use the same fake dataset. See the Sandbox section for test credentials and pre-seeded data.

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_typeMeaning
AUTH_ERRORMissing, invalid, expired, or revoked API key
INVALID_REQUESTMalformed request, missing required fields, bad input
ITEM_ERRORBank, account, session, or connection issue
API_ERRORServer-side error — safe to retry

See the full error code reference below.

Banks

These endpoints are public — no API key required.

List banks

GET /banks No auth required

Query parameters

ParameterTypeDescription
searchstringoptionalFilter by bank name (e.g. ?search=erste)
statusstringoptionallive, sandbox, or coming_soon
limitintegeroptionalMax results (default 50)
offsetintegeroptionalPagination offset

Get one bank

GET /banks/:id No auth required
# 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

POST /connect API key required

Creates a 10-minute session and returns a URL to open for bank authorization.

Request body

FieldTypeDescription
bank_idstringrequiredBank identifier (e.g. raiffeisen)
user_idstringrequiredYour internal identifier for this user
redirect_urlstringrequiredWhere to send the user after authorization

Check status

GET /connect/:session_token/status API key required

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

POST /connect/exchange API key required

Exchanges the one-time public_token for a permanent connection_id. Must be called from your backend.

Request body

FieldTypeDescription
public_tokenstringrequiredThe token from the onSuccess callback
Each 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

GET /connections API key required

Returns all connections made through your API key.

Revoke a connection

DELETE /connections/:id API key required

Permanently revokes a user's bank connection. Required for GDPR "right to be forgotten" requests. The user will need to re-authorize to reconnect.

Revoking a connection fires the ITEM.CONNECTION_REVOKED webhook if registered.

Accounts

List accounts

GET /accounts Requires 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

ParameterTypeDescription
bank_idstringoptionalFilter to one bank (e.g. ?bank_id=raiffeisen)

Get balance

GET /accounts/:id/balance Requires 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

GET /accounts/:id/identity Requires 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

POST /accounts/:id/sync Requires 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

GET /accounts/:id/transactions Requires transactions:read

Query parameters

ParameterTypeDescription
fromdateoptionalStart date YYYY-MM-DD
todateoptionalEnd date YYYY-MM-DD
categorystringoptionalgroceries, rent, dining, utilities, transport, entertainment, subscriptions, salary, transfer
limitintegeroptionalMax results (default 50, max 200)
cursorstringoptionalEnable 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

GET /accounts/:id/recurring Requires 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

  1. Extract t (timestamp) and v1 (signature) from the header
  2. Construct the signed string: t + "." + raw_request_body
  3. Compute HMAC-SHA256 of that string using your webhook secret
  4. Compare your result to v1 — reject if they don't match
  5. 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

POST/developers/register

Login

POST/developers/login

Returns a devses_… session token valid for 24 hours.

Logout

POST/developers/logout

Get account

GET/developers/medevses token required

Developer Portal — API Keys

List keys

GET/developers/keysdevses token required

Returns key prefix, app name, environment, and status. Full keys are never shown after creation.

Generate a key

POST/developers/keysdevses token required
The full key is returned once only — copy it immediately and store it securely.

Revoke a key

DELETE/developers/keys/:keydevses token required

Takes effect immediately. Any requests using the revoked key will receive a 401 REVOKED_API_KEY error.

Developer Portal — Usage

GET/developers/usagedevses token required

Returns total request counts, breakdown by endpoint, error rates, and recent errors for all your API keys.

Developer Portal — Webhooks

Register a webhook

POST/developers/webhooksdevses token required

Request body

FieldTypeDescription
urlstringrequiredHTTPS URL — cannot be localhost or a private IP
eventsarrayoptionalEvent types to receive. Defaults to all events if omitted.
The webhook secret is returned once only — save it to verify incoming events.

List webhooks

GET/developers/webhooksdevses token required

Remove a webhook

DELETE/developers/webhooks/:iddevses token required

Error Codes

error_codeTypeHTTPDescription
MISSING_API_KEYAUTH_ERROR401No Authorization header provided
INVALID_API_KEYAUTH_ERROR401API key is not recognised
REVOKED_API_KEYAUTH_ERROR401API key has been revoked
MISSING_DEV_TOKENAUTH_ERROR401Developer session token not provided
INVALID_DEV_TOKENAUTH_ERROR401Session token is invalid
EXPIRED_DEV_TOKENAUTH_ERROR401Session expired — login again
INSUFFICIENT_PERMISSIONSAUTH_ERROR403API key lacks required permission scope
MISSING_FIELDSINVALID_REQUEST400Required request fields are missing
INVALID_EMAILINVALID_REQUEST400Email address format is invalid
PASSWORD_TOO_SHORTINVALID_REQUEST400Password must be at least 8 characters
INVALID_CURSORINVALID_REQUEST400Cursor value not recognised — omit to restart sync
EMAIL_TAKENINVALID_REQUEST409Email already registered
BANK_NOT_FOUNDITEM_ERROR404Bank ID not recognised
BANK_NOT_AVAILABLEITEM_ERROR400Bank is listed as coming_soon — not yet connectable
SESSION_NOT_FOUNDITEM_ERROR404Session token not found
SESSION_EXPIREDITEM_ERROR41010-minute session has expired
INVALID_PUBLIC_TOKENITEM_ERROR404Public token not found
PUBLIC_TOKEN_ALREADY_USEDITEM_ERROR410Public token was already exchanged
PUBLIC_TOKEN_EXPIREDITEM_ERROR4105-minute public token window has passed
ACCOUNT_NOT_FOUNDITEM_ERROR404Account doesn't exist or doesn't belong to this key
IDENTITY_UNAVAILABLEITEM_ERROR404Bank doesn't support identity data
CONNECTION_NOT_FOUNDITEM_ERROR404Connection doesn't exist or doesn't belong to this key
CONNECTION_ALREADY_REVOKEDITEM_ERROR409Connection was already revoked
INTERNAL_ERRORAPI_ERROR500Server 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

KeyApp
od_test_sk_9x2mABC123sandboxMani App
od_test_sk_demo456developerDemo Developer

Pre-seeded accounts

Account IDBankCurrencyType
acc_rai_001RaiffeisenRSDCurrent
acc_rai_002RaiffeisenEURSavings
acc_ers_001Erste BankRSDCurrent
acc_aik_001AIK BankaRSDCurrent

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

POST/sandbox/authorizeNo auth required

Called by the sandbox auth page to programmatically authorize a session. Pass { "session_token": "sess_..." }. Useful for automated testing.

Try it live: Open api.opendinar.com/demo.html to see the full Link Widget flow in the sandbox.