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
Sandbox only. OpenDinar is not yet connected to any real bank. Our registration as an Account Information Service Provider with the National Bank of Serbia is in progress — until it is granted, every bank, account, and transaction in this API is simulated test data. The full API is free to explore in the sandbox today.

Supported banks

BankStatusSandbox data
Raiffeisen BankaSandboxAccounts, Balance, Transactions, Identity
Erste BankSandboxAccounts, Balance, Transactions
AIK BankaSandboxAccounts, Balance, Transactions
Banca IntesaSandboxNo seeded data yet
OTP BankaSandboxNo seeded data yet
UniCredit Bank SrbijaSandboxNo 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:

PrefixTypeUse for
od_test_sk_Sandbox keyTesting — all data is simulated
od_live_sk_Live keyComing soon — live keys will be issued once real bank connections launch. Only sandbox keys exist today.
Keep your key secret. Never expose it in client-side JavaScript or commit it to version control. Use environment variables.

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();
For quick sandbox experiments you can pass 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

EnvironmentKey prefixDataBilled
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
Every sandbox connection gets its own private copy of the test dataset. See the Sandbox section for details.

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
We recommend using the /v1 prefix in new integrations. When a future /v2 ships with breaking changes, apps pinned to /v1 will keep working unchanged.

Rate Limits

ScopeLimitCounted per
Banking API (/banks, /connect, /connections, /accounts, /transactions, /link, /sandbox)300 requests / 15 minAPI key (or IP if no key)
/developers/login and /developers/register10 attempts / 15 minIP 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_typeMeaning
AUTH_ERRORMissing, invalid, expired, or revoked API key
INVALID_REQUESTMalformed request, missing required fields, bad input
ITEM_ERRORBank, account, session, or connection issue
RATE_LIMIT_ERRORToo many requests — slow down and retry later
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 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.

POST /link/token/create API key required — call from your backend

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

FieldTypeDescription
user_idstringoptionalYour 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

POST /connect API key or link token

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, 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

DELETE /connections/:id Requires 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.

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

Export a connection's data (right of access)

GET /connections/:id/gdpr/data API key required

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.

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_1a2b3c4d5e6f7a8b",
  "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.

Get auth (account verification)

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

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_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

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
}

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

  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

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

POST/developers/login

Returns a devses_… session token valid for 24 hours. Unverified accounts get an EMAIL_NOT_VERIFIED error.

Logout

POST/developers/logout

Get account

GET/developers/medevses token required

Email verification

GET/developers/verify-emailvia the link in the email
POST/developers/resend-verification

Resend takes just { "email": "…" } — no login needed.

Password reset

POST/developers/forgot-password
POST/developers/reset-password

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

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

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)

GET/developers/gdpr/datadevses token required

Download everything (right to portability)

GET/developers/gdpr/exportdevses token required

Returns all your account data as a JSON download.

Delete your account (right to erasure)

DELETE/developers/gdpr/medevses token + password confirmation

Permanently deletes your account, keys, webhooks, and connections, and anonymises request logs. This cannot be undone.

Error Codes

error_codeTypeHTTPDescription
MISSING_API_KEYAUTH_ERROR401No Authorization header provided
INVALID_LINK_TOKENAUTH_ERROR401Link token not recognised
EXPIRED_LINK_TOKENAUTH_ERROR401Link token older than 30 minutes — create a new one
INVALID_CREDENTIALSAUTH_ERROR401Email or password is incorrect
EMAIL_NOT_VERIFIEDAUTH_ERROR403Verify your email before logging in
WRONG_TOKEN_TYPEAUTH_ERROR401Endpoint needs a developer session token, not an API key
DEVELOPER_NOT_FOUNDAUTH_ERROR401Session refers to an account that no longer exists
INVALID_PASSWORDAUTH_ERROR401Password confirmation failed (account deletion)
INVALID_ADMIN_KEYAUTH_ERROR401Admin endpoints — owner only
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
INVALID_ENVIRONMENTINVALID_REQUEST400environment must be sandbox or live
MISSING_APP_NAMEINVALID_REQUEST400app_name is required when creating a key
MISSING_PUBLIC_TOKENINVALID_REQUEST400public_token is required for the exchange
MISSING_SESSION_TOKENINVALID_REQUEST400session_token is required (sandbox authorize)
CONSENT_REQUIREDINVALID_REQUEST400Privacy Policy + Terms must be accepted at registration
NAME_REQUIREDINVALID_REQUEST400Name is missing (waitlist)
INPUT_TOO_LONGINVALID_REQUEST400A field exceeds its maximum length
PASSWORD_REQUIREDINVALID_REQUEST400Password confirmation is required for this action
MISSING_TOKENINVALID_REQUEST400Verification token is missing
INVALID_RESET_TOKENINVALID_REQUEST400Password-reset link is invalid or already used
EXPIRED_RESET_TOKENINVALID_REQUEST400Password-reset link has expired — request a new one
INVALID_WEBHOOK_URLINVALID_REQUEST400Webhook URL must be a public HTTPS address
INVALID_EVENTSINVALID_REQUEST400events must be an array of supported event names
NOT_FOUNDINVALID_REQUEST404Route does not exist
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
SESSION_NOT_PENDINGITEM_ERROR400Session was already authorized or 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
AUTH_UNAVAILABLEITEM_ERROR404Bank doesn't support account verification, or the account has no IBAN
CONNECTION_NOT_FOUNDITEM_ERROR404Connection doesn't exist or doesn't belong to this key
CONNECTION_ALREADY_REVOKEDITEM_ERROR409Connection was already revoked
CONSENT_EXPIREDITEM_ERROR401The user's 90-day consent has lapsed — reconnect them
KEY_NOT_FOUNDITEM_ERROR404API key does not exist or is not yours
KEY_ALREADY_REVOKEDITEM_ERROR409API key was already revoked
WEBHOOK_NOT_FOUNDITEM_ERROR404Webhook does not exist or is not yours
RATE_LIMIT_EXCEEDEDRATE_LIMIT_ERROR429Too many requests — check the RateLimit-* headers and retry later
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

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.

BankTest accounts you get per connection
RaiffeisenRSD current account + EUR savings account, with transaction history
Erste BankRSD current account, with transaction history
AIK BankaRSD current account, with transaction history
Banca Intesa, OTP, UniCreditNo 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

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.