API reference

Base URL https://plaiduz-api-production.up.railway.app/v1. All requests and responses are JSON. Every non-2xx response includes a request_id.

Full generated reference (every endpoint, from openapi.json) →

Quickstart

  1. Get a client_id and secret from the dashboard.
  2. Call /link/token/create from your server to get a short-lived link_token.
  3. Open the Link widget with that token; the user connects their bank and you receive a public_token.
  4. Exchange it for a permanent access_token via /item/public_token/exchange.
  5. Call /accounts/balance/get, /transactions/get and /payment/initiate.

Authentication

Send your credentials as headers (recommended) or in the JSON body. The demo sandbox keys are shown below.

bash
Silkroute-Client-Id: client_demo_5f3a9c
Silkroute-Secret: sandbox-secret-DEMO-do-not-use
POST/v1/item/public_token/exchange

Exchanges the one-time public_token returned by Link for a permanent access_token. Revealed exactly once. A public_token is valid for 30 minutes. If you created the link_token with code_challenge (PKCE, method S256), send the matching code_verifier.

Request

json
{ "public_token": "public-sandbox-…" }

Response 200

json
{
  "access_token": "access-sandbox-…",
  "item_id": "item_01H…"
}
POST/v1/accounts/balance/get

Returns real-time balances for every open account on the Item. Amounts are in major units.

Request

json
{ "access_token": "access-sandbox-…" }

Response 200

json
{
  "accounts": [{
    "account_id": "acc_01M…",
    "name": "UZCARD Salary", "mask": "4415",
    "balances": {
      "current": 79802.37, "available": 69160.47,
      "iso_currency_code": "UZS" }
  }]
}
POST/v1/transactions/get

Paginated, enriched transactions. amount is positive when money leaves the account.

Request

json
{
  "access_token": "…",
  "start_date": "2026-08-01",
  "end_date": "2026-09-29",
  "options": { "count": 100, "offset": 0 }
}

Response 200

json
{
  "total_transactions": 78,
  "transactions": [{
    "transaction_id": "txn_…", "amount": 45000,
    "pending": false, "date": "2026-09-20",
    "merchant_name": "Korzinka",
    "personal_finance_category": {
      "primary": "FOOD_AND_DRINK" }
  }]
}
POST/v1/payment/initiate

Initiates a direct A2A transfer. Requires an idempotency_key; settlement is confirmed asynchronously. The response carries the simulated platform fee: at most 0.5% (50 bps), 0.3% (30 bps) at monthly volume, capped per payment. Sandbox: nothing is charged.

Request

json
{
  "access_token": "…",
  "account_id": "acc_…",
  "amount": 150000, "currency": "UZS",
  "creditor": {
    "name": "OOO Beneficiary",
    "account_number": "20208000900123456789" },
  "idempotency_key": "order-4821"
}

Response 200

json
{
  "payment_id": "pay_01M…",
  "status": "executing",
  "amount": 150000, "currency": "UZS",
  "fee": { "amount": 750, "currency": "UZS",
           "bps": 50, "tier": "standard", "simulated": true }
}
POST/v1/identity/get

Account-holder names, e-mail, masked phone and city, fetched from the bank on every call. Silkroute stores none of it. Sandbox identities are synthetic.

Request

json
{ "access_token": "access-sandbox-…" }

Response 200

json
{
  "accounts": [{
    "account_id": "acc_01M…", "mask": "4415",
    "owners": [{
      "names": ["Dilnoza Karimova"],
      "phone_numbers": [{ "data": "+998 ** *** ** 42" }],
      "addresses": [{ "data": { "city": "Samarkand", "country": "UZ" } }]
    }]
  }],
  "data_source": "synthetic_sandbox"
}
POST/v1/user/insights/summary

Consent centre (Authorization: Bearer user-…): the user’s cash flow per month and spending per category across their connected banks, in whole UZS.

Request

json
{ "period": "90d" }

Response 200

json
{
  "period": { "start": "2026-07-12", "end": "2026-10-09" },
  "cashflow": {
    "totals": { "inflow": 28500000, "outflow": 12900000, "net": 15600000 },
    "savings_rate_pct": 55 },
  "categories": [{ "category": "RENT_AND_UTILITIES",
    "amount": 7900000, "share_pct": 61 }]
}
POST/v1/user/assistant/ask

Consent centre: a read-only assistant. It can only call three fixed tools (cash flow, category spend, risk score) for this user; every figure in the answer is in facts. Without an LLM key the answer is calculated, not generated. Limited per user per day.

Request

json
{
  "question": "Where did my money go?",
  "language": "en"
}

Response 200

json
{
  "answer": "From 2026-07-12 to 2026-10-09 you received 28 500 000 UZS …",
  "mode": "llm",
  "sources": [{ "tool": "get_cashflow", "period": { … } }],
  "facts": [ … ],
  "usage": { "questions_today": 1, "daily_limit": 10 }
}

Platform products

Variable Recurring Payments (VRP)

Demo data

Create a mandate on the user’s account, wait for the user to approve it (consent centre or Telegram), then charge variable amounts within the limits. Above-limit or inactive-mandate attempts come back with status "rejected" and a rejection_code — HTTP 200, not an error — and are still logged.

POST/v1/vrp/mandate/create

Requests a mandate (status awaiting_authorization) and notifies the user. Limits are in major units; period windows follow the Asia/Tashkent calendar; expiry ≤ 3 years.

Request

json
{
  "access_token": "access-sandbox-…",
  "account_id": "acc_01J…",
  "payee": { "name": "Shahar Kommunal",
             "account_number": "20208000900765432109" },
  "limits": { "max_per_payment": 500000,
              "period": "month",
              "period_limit": 1000000 },
  "expires_at": "2027-10-01T00:00:00Z"
}

Response 200

json
{
  "mandate": {
    "mandate_id": "mandate_01J…",
    "status": "awaiting_authorization",
    "payee": { "name": "Shahar Kommunal",
               "account_mask": "••••2109" },
    "limits": { "max_per_payment": 500000,
                "period": "month", "period_limit": 1000000 },
    "usage": { "used": 0, "remaining": 1000000 }
  }
}
POST/v1/vrp/payment/create

Charges a variable amount under an active mandate. idempotency_key is required. Accepted charges settle asynchronously as A2A payments.

Request

json
{
  "mandate_id": "mandate_01J…",
  "amount": 645000,
  "idempotency_key": "bill-2026-10-heating"
}

Response 200

json
{
  "charge": {
    "charge_id": "vrpc_01J…",
    "status": "rejected",
    "rejection_code": "LIMIT_PER_PAYMENT_EXCEEDED",
    "amount": 645000, "payment_id": null
  },
  "remaining_in_period": 399700
}

More endpoints

POST
/v1/vrp/mandate/getStatus, limits and current-period usage.
/v1/vrp/mandate/listFilter by status or user_ref.
/v1/vrp/mandate/revokeCancel as the payee (revoked_by = client).
/v1/vrp/charges/listEvery attempt, including rejected ones.

Consent centre (user_token)

Mint a user_token on your server (POST /v1/user/token/create) and call these on the user’s behalf — or the user taps Approve / Pause / Revoke in Telegram. Keep the user_token server-side.

POST · Bearer user_token
/v1/user/vrp/mandates/listThe user’s mandates.
/v1/user/vrp/mandate/authorizeApprove with SCA (sandbox OTP 123456).
/v1/user/vrp/mandate/pauseCharges are refused until resumed.
/v1/user/vrp/mandate/resumeResume a paused mandate.
/v1/user/vrp/mandate/revokeRevoke permanently — effective on the next charge.
/v1/user/vrp/charges/listCharges under one of the user’s mandates.

Webhooks

VRP_MANDATE_STATUS_UPDATED

awaiting_authorization → active / paused / revoked / expired / declined.

Webhooks

VRP_PAYMENT_STATUS_UPDATED

A charge was accepted, settled or rejected (with rejection_code).

Error codes

error_code
INVALID_MANDATE_LIMITSperiod_limit < max_per_payment, expiry in the past or more than 3 years ahead (400).
MANDATE_STATE_CONFLICTThat transition is not allowed from the current status (409).
MANDATE_NOT_FOUNDUnknown mandate, or not yours (404).
rejection_codeLIMIT_PER_PAYMENT_EXCEEDED · PERIOD_LIMIT_EXCEEDED · PERIOD_COUNT_EXCEEDED · MANDATE_NOT_AUTHORIZED · MANDATE_PAUSED · MANDATE_REVOKED · MANDATE_EXPIRED · INSUFFICIENT_FUNDS · ITEM_REVOKED

Credit Passport

Demo data

Ask the borrower for consent, wait until they grant it, then fetch a 300–850 cash-flow score with five explained factors. The consent_token is returned once and works only while access is granted; every fetch is logged and shown to the borrower.

  • Factors: income_stability 0.30 · inflow_regularity 0.15 · spending_ratio 0.20 · balance_resilience 0.15 · debt_burden 0.20. score = 300 + 550 × Σ(weight × sub_score) / 100.
  • Flags: HIGH_DEBT_BURDEN (debt service ≥ 50% of income, score capped at 599), INSUFFICIENT_HISTORY (< 3 full months), NO_INCOME_DETECTED.
POST/v1/credit_passport/consent/request

Creates a pending consent and notifies the borrower. period_months: 3, 6 or 12; access_ttl_days: 1–90 (default 30). An unanswered request lapses after 7 days. The user must have at least one connected bank.

Request

json
{
  "user_ref": "borrower_4821",
  "purpose": "Consumer loan application #4821",
  "period_months": 6,
  "access_ttl_days": 30
}

Response 200

json
{
  "consent_id": "pcon_01J…",
  "consent_token": "consent-sandbox-…",
  "status": "pending",
  "requester_name": "Acme Microfinance",
  "request_expires_at": "2026-10-10T10:00:00Z"
}
POST/v1/credit_passport/get

Returns the passport while consent is granted. A report is reused for 24 hours; each call is still logged and notified.

Request

json
{
  "consent_token": "consent-sandbox-…"
}

Response 200

json
{
  "passport": {
    "score": 712, "grade": "B", "confidence": "high",
    "model_version": "cf-v1", "flags": [],
    "factors": [{
      "key": "debt_burden", "weight": 0.2,
      "sub_score": 100, "impact": "positive",
      "explanation": "No loan payments detected."
    }, …],
    "metrics": { "months_observed": 6,
                 "avg_monthly_income": 9500000,
                 "debt_to_income": 0 },
    "data_source": "synthetic_sandbox"
  }
}

More endpoints

POST
/v1/credit_passport/consent/getStatus, access window and fetch count.
/v1/credit_passport/consent/listFilter by status or user_ref; includes the latest score.
/v1/credit_passport/consent/revokeEnd your own access early (revoked_by = client).

Consent centre (user_token)

The borrower sees who asked, why, and every access — and decides. In Telegram they get Grant / Deny on a request and Revoke on every access.

POST · Bearer user_token
/v1/user/passport/consents/listRequests with their full access log.
/v1/user/passport/consent/grantGrant with SCA (sandbox OTP 123456).
/v1/user/passport/consent/denyRefuse a pending request.
/v1/user/passport/consent/revokeRevoke instantly — the next fetch gets 403.
/v1/user/passport/previewMy own score and explanation (not shared, not logged as access).
/v1/user/items/removeDisconnect a bank: its data is no longer analysed.

Webhooks

CREDIT_PASSPORT_CONSENT_UPDATED

pending → granted / denied / revoked / expired.

Error codes

error_code
CONSENT_PENDING · CONSENT_DENIED · CONSENT_REVOKED · CONSENT_EXPIREDNo active consent for this token (403).
NO_CONNECTED_ACCOUNTSThe user has no connected bank to analyse (400).
INVALID_CONSENT_TOKENUnknown consent_token (400).
CONSENT_STATE_CONFLICTThat decision is no longer possible (409).

Auto-Balancing

Demo data

Rules belong to the user, so they are created with a user_token from your server (or in the consent centre). The engine projects the protected account from scheduled debits and detected recurring outflows; before a shortfall it suggests a top-up (notify) or transfers immediately (auto). amount = min(shortfall, max_transfer_amount, donor balance above source_min_balance).

  • Sandbox: a rule name containing FLAKY makes the first transfer attempt fail once (retry demo); FAIL makes every attempt fail.
POST/v1/user/balancing/rules/create

Creates the rule and evaluates it at once. horizon_days 1–60 (default 7) or a deadline_date; both accounts must be the user’s own, in one currency.

Request

json
{
  "name": "Keep my card topped up",
  "target_account_id": "acc_card…",
  "source_account_id": "acc_savings…",
  "horizon_days": 7,
  "max_transfer_amount": 2000000,
  "source_min_balance": 5000000,
  "mode": "notify"
}

Response 200

json
{
  "rule": { "rule_id": "abr_01J…", "status": "active", "mode": "notify" },
  "evaluation": {
    "evaluation": { "action": "transfer", "amount": 900000,
                    "shortfall_date": "2026-10-17",
                    "projected_min_balance": -899877 },
    "execution": { "execution_id": "abx_01J…", "status": "suggested" }
  }
}
POST/v1/user/balancing/executions/confirm

Confirms a suggestion exactly once (the same as the Telegram Confirm button). A second confirm returns 409.

Request

json
{
  "execution_id": "abx_01J…"
}

Response 200

json
{
  "execution": {
    "execution_id": "abx_01J…", "status": "executing",
    "amount": 900000, "decided_by": "user",
    "attempts": 1, "max_attempts": 3
  },
  "result": "transfer_confirmed"
}

More endpoints

POST
/v1/balancing/rules/listYour users’ rules (filter by user_ref / status).
/v1/balancing/executions/listEvery decision and transfer attempt.
/v1/balancing/rules/pauseSupport action: pause a user’s rule (audited).
/v1/sandbox/upcoming_debit/createSandbox: schedule a debit (standing order) to trigger a forecast.

The user’s rules (user_token)

Users see and control every rule — in your app, the consent centre or the Mini App — and get Confirm / Dismiss in Telegram.

POST · Bearer user_token
/v1/user/balancing/rules/listRules with their latest projection.
/v1/user/balancing/rules/updateChange limits, horizon, deadline or mode.
/v1/user/balancing/rules/pausePause — open suggestions expire.
/v1/user/balancing/rules/resumeResume a paused rule.
/v1/user/balancing/rules/deleteDelete a rule.
/v1/user/balancing/rules/evaluate“Check now”; dry_run=true only projects.
/v1/user/balancing/executions/listThe decision log with each attempt.
/v1/user/balancing/executions/confirmConfirm a suggested top-up.
/v1/user/balancing/executions/dismissDismiss a suggested top-up.

Webhooks

BALANCING_TRANSFER_UPDATED

suggested → executing → executed / failed; dismissed; expired.

Error codes

error_code
SAME_ACCOUNT · CURRENCY_MISMATCH · INVALID_DEADLINEInvalid rule (400).
ACCOUNT_NOT_FOUNDAn account is not one of the user’s own connected accounts (404).
EXECUTION_STATE_CONFLICTThe suggestion is no longer open (409).
skip_reason / last_errorSOURCE_BELOW_FLOOR · TECHNICAL_ERROR (retried) · INSUFFICIENT_FUNDS · ITEM_REVOKED

Treasury & Split Payments

Demo data

B2B only: every call acts on behalf of a treasury member of your company — send actor_ref in the body or the Silkroute-Actor header. Roles: director (everything) and accountant (view, preview and initiate payments). The API-key holder can bootstrap the first director; the last active director cannot be demoted or disabled (409 LAST_DIRECTOR). All endpoints are POST under /v1/treasury. A split pays one counterparty from several accounts: each part is a separate A2A bank payment, and the overall status is always derived from the parts.

  • Sandbox: sandbox_simulate: { fail_part: <part_no>, failure: "transient" | "permanent", refuse_return: true } on /split/create (sandbox keys only; otherwise SANDBOX_ONLY) lets you watch retries, re-allocation and compensation.
  • Execution: each part is a separate A2A payment, retried on a technical bank error (up to 3 attempts per part). If a part fails for good, auto mode re-allocates the missing amount once to other accounts; otherwise already-settled parts are compensated (the counterparty returns them).
  • Sub-treasury: the optional account_ids on /balances, /split/preview, /split/create and /split/list limits the call to a subset of the company’s treasury accounts (for example one branch or one currency pool). Auto allocation and re-allocation only use those accounts; manual shares outside the scope are rejected with UNKNOWN_ACCOUNT; balances and lists only cover splits funded from those accounts. Omit it to use the whole organisation’s treasury (default).
POST/v1/treasury/split/create

Pays one counterparty {name, account_number} the total_amount (major units, UZS). allocation {mode:"auto"} fills accounts by priority while respecting min_reserve; {mode:"manual", shares:[…]} takes up to 10 shares, each {account_id, amount} or {account_id, percent}. idempotency_key is required — the same key replays the result and nothing is paid twice. Optional reference and account_ids (sub-treasury scope).

Request

json
{
  "actor_ref": "accountant",
  "counterparty": { "name": "OOO Paxta Trade",
                    "account_number": "2020…4321" },
  "total_amount": 50000000,
  "allocation": { "mode": "auto" },
  "account_ids": ["acc_01J…", "acc_01J…"],
  "idempotency_key": "inv-0418-paxta",
  "reference": "Invoice 0418"
}

Response 200

json
{
  "split": {
    "split_id": "split_01J…",
    "status": "completed",
    "counterparty": { "name": "OOO Paxta Trade", "account_mask": "••••4321" },
    "total_amount": 50000000,
    "currency": "UZS",
    "allocation_mode": "auto",
    "created_by": { "member_ref": "accountant",
                    "display_name": "Bekzod (Accountant)",
                    "role": "accountant" },
    "parts": [
      { "part_no": 1, "account_id": "acc_01J…",
        "account_label": "Tumaris · Settlement ••1111",
        "amount": 35000000, "status": "settled",
        "attempts": 1, "payment_id": "pay_01J…" },
      { "part_no": 2, "account_id": "acc_01J…",
        "account_label": "Lochin · Settlement ••2222",
        "amount": 15000000, "status": "settled",
        "attempts": 1, "payment_id": "pay_01J…" }
    ],
    "progress": { "settled": 50000000, "in_flight": 0,
                  "failed": 0, "compensated": 0 }
  }
}
POST/v1/treasury/split/get

Returns one split with its parts, attempts and progress.

Request

json
{
  "actor_ref": "accountant",
  "split_id": "split_01J…"
}

Response 200

json
{
  "split": {
    "split_id": "split_01J…",
    "status": "completed",
    "counterparty": { "name": "OOO Paxta Trade", "account_mask": "••••4321" },
    "total_amount": 50000000,
    "currency": "UZS",
    "allocation_mode": "auto",
    "created_by": { "member_ref": "accountant",
                    "display_name": "Bekzod (Accountant)",
                    "role": "accountant" },
    "parts": [
      { "part_no": 1, "account_id": "acc_01J…",
        "account_label": "Tumaris · Settlement ••1111",
        "amount": 35000000, "status": "settled",
        "attempts": 1, "payment_id": "pay_01J…" },
      { "part_no": 2, "account_id": "acc_01J…",
        "account_label": "Lochin · Settlement ••2222",
        "amount": 15000000, "status": "settled",
        "attempts": 1, "payment_id": "pay_01J…" }
    ],
    "progress": { "settled": 50000000, "in_flight": 0,
                  "failed": 0, "compensated": 0 }
  }
}

More endpoints

POST
/v1/treasury/balancesUnified balances: per currency {current, available, reserved, spendable}, per bank and per account (with min_reserve and spendable), 30-day inflow / outflow and splits_in_flight. Optional account_ids.
/v1/treasury/split/previewShows the allocation for a split; nothing moves. Optional account_ids.
/v1/treasury/split/listSplits, with optional status and limit (≤ 200) and the account_ids scope.
/v1/treasury/split/retry_compensationDirector only: retry a counterparty return that was refused.
/v1/treasury/members/listTreasury members and their roles.
/v1/treasury/members/addAdds a member as director or accountant. The API-key holder can bootstrap the first director.
/v1/treasury/members/updateChanges a role or disables a member; the last active director is protected (409 LAST_DIRECTOR).
/v1/treasury/accounts/addDirector only: connects an account with label, priority (1–1000, default 100; lower is used first in auto allocation) and min_reserve (never spent below it).
/v1/treasury/accounts/removeDirector only: removes a treasury account.
/v1/treasury/audit/listDirector only: every member / account / split action with the acting member.

Webhooks

SPLIT_PAYMENT_STATUS_UPDATED

{split_id, new_status, total_amount} on every status change.

Error codes

error_code
NO_TREASURY_ACCOUNTS · INSUFFICIENT_TREASURY_FUNDSThere are no treasury accounts in scope, or their spendable funds do not cover the split (TREASURY_ERROR).
UNKNOWN_ACCOUNT · DUPLICATE_ACCOUNT · CURRENCY_MISMATCHA share names an account that is not in your treasury or is outside the account_ids scope; an account is added twice; currencies differ (TREASURY_ERROR).
MIXED_SHARES · SHARES_DO_NOT_SUM · INVALID_SHARE · TOO_MANY_PARTSInvalid manual allocation: amount and percent mixed, shares that do not add up to the total, a malformed share, or more than 10 parts (TREASURY_ERROR).
EXCEEDS_HEADROOMA share is more than the account can spend above its min_reserve (TREASURY_ERROR).
NOTHING_TO_RETRYThere is no refused counterparty return to retry (TREASURY_ERROR).
ACTOR_REQUIRED · NOT_A_TREASURY_MEMBER · MEMBER_DISABLED · INSUFFICIENT_ROLEThe actor is missing, unknown, disabled, or lacks the role for this call (INVALID_REQUEST).
MEMBER_EXISTS · LAST_DIRECTORThe member already exists (INVALID_REQUEST); the last active director cannot be demoted or disabled (409).
SANDBOX_ONLYsandbox_simulate was sent with a non-sandbox key (INVALID_REQUEST).
split.statusprocessing · completed · partially_failed · compensating · compensated · compensation_failed · failed
parts[].statuspending · executing · settled · failed · rejected · replaced · compensating · compensated · compensation_failed · cancelled

White-label & platform

Demo data

For banks, MFIs, fintechs and marketplaces that ship open banking under their own brand — Open Banking as a Service. Most of it lives in the dashboard: branding, API keys, rate limits, usage analytics and webhooks are managed per organisation, and every change is audited. The API itself is two POST calls: /v1/link/token/create (with an optional sandbox branding override) and /v1/link/branding, which the Link widget calls with its link_token. “Secured by Silkroute” always stays visible on the consent flow.

  • Branding: saved per organisation in the dashboard (Branding page; owners, admins and developers) and applied to every Link consent flow. “Preview in Link” shows an unsaved look in a sandbox session and saves nothing. “Secured by Silkroute” always stays visible.
  • API keys: created in the dashboard — the secret is shown exactly once and only its hash is stored. Rotate (the old secret stops immediately) or revoke. Keys are sandbox or production; production keys can be created but stay inert in this sandbox build (403 PRODUCTION_NOT_ENABLED). The shared public demo key cannot be rotated, re-limited or revoked (409 PROTECTED_KEY).
  • Rate limits and usage: each key has a limit (default 600 a minute, 1–100000). Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; over the limit the call fails with 429 RATE_LIMIT_EXCEEDED and a Retry-After header. The dashboard shows requests per day (ok / client errors / throttled / server errors), error rate, p50 / p95 latency per endpoint, requests and throttles per key, and request logs you can filter by key.
  • Webhooks: endpoints belong to your organisation, and each delivery is signed with Silkroute-Signature: sha256=<HMAC-SHA256 of the raw body with your webhook secret> — check it with verifyWebhook from the SDK. A failed delivery is retried with exponential backoff (2^n minutes, capped at 6 h) and then marked dead. In the dashboard, enable or disable endpoints, redeliver an event and send a test event. Every platform change lands in the audit log.
POST/v1/link/token/create

Creates the link_token for one end user (Silkroute-Client-Id / Silkroute-Secret); user_ref is required. The optional branding object — display_name (1–40 characters, no markup), primary_color and accent_color (#RRGGBB), logo_url, support_url and privacy_url (https only, up to 500 characters, no credentials), consent_headline (up to 120) — brands just that one session; fields you leave out fall back to your saved branding, or to the defaults. It is sandbox-only: any other key gets 400 SANDBOX_ONLY, and invalid values get 400 INVALID_BRANDING. Production sessions always use the branding saved in the dashboard.

Request

json
{
  "user_ref": "user_42",
  "products": ["transactions"],
  "branding": {
    "display_name": "NovaPay",
    "primary_color": "#7C3AED",
    "accent_color": "#0B0F19",
    "logo_url": "https://cdn.novapay.uz/logo.svg",
    "support_url": "https://novapay.uz/help",
    "privacy_url": "https://novapay.uz/privacy",
    "consent_headline": "Connect your bank to NovaPay"
  }
}

Response 200

json
{
  "link_token": "link-sandbox-3f9a…",
  "expiration": "2026-10-03T10:30:00.000Z"
}
POST/v1/link/branding

Called by the Link widget and authenticated by the link_token. Returns the branding to render: on_primary_color is the readable text colour picked by WCAG contrast, secured_by is always "Silkroute", and source tells where the look came from — organization (saved in the dashboard), preview (a sandbox override on this session) or default (nothing saved yet).

Request

json
{
  "link_token": "link-sandbox-3f9a…"
}

Response 200

json
{
  "branding": {
    "display_name": "NovaPay",
    "logo_url": "https://cdn.novapay.uz/logo.svg",
    "primary_color": "#7C3AED",
    "on_primary_color": "#FFFFFF",
    "accent_color": "#0B0F19",
    "support_url": "https://novapay.uz/help",
    "privacy_url": "https://novapay.uz/privacy",
    "consent_headline": "Connect your bank to NovaPay",
    "secured_by": "Silkroute",
    "source": "preview"
  }
}

Webhooks

TRANSACTIONS_SYNCED

New transactions are available for an Item.

Webhooks

ITEM_LOGIN_REQUIRED

The user must re-authenticate the Item.

Webhooks

ITEM_ERROR

An Item entered an error state.

Webhooks

PAYMENT_STATUS_UPDATED

An A2A payment changed status (executing, settled, failed…).

Webhooks

DEFAULT_UPDATE

Test / generic update events — the dashboard’s test event arrives as DEFAULT_UPDATE.

Webhooks

VRP_MANDATE_STATUS_UPDATED

A VRP mandate was approved, paused, resumed, revoked or expired.

Webhooks

VRP_PAYMENT_STATUS_UPDATED

A charge under a VRP mandate was accepted, settled or rejected.

Webhooks

CREDIT_PASSPORT_CONSENT_UPDATED

A Credit Passport consent was granted, denied, revoked or expired.

Webhooks

BALANCING_TRANSFER_UPDATED

An auto-balancing top-up was suggested, executed, skipped or failed.

Webhooks

SPLIT_PAYMENT_STATUS_UPDATED

A split payment changed status (completed, compensated…).

Error codes

error_code
INVALID_BRANDINGA branding value is invalid: display_name over 40 characters or with markup; a colour that is not #RRGGBB; a link that is not https, longer than 500 characters or with credentials; or consent_headline over 120 characters (400, INVALID_INPUT).
SANDBOX_ONLYA branding override was sent on /v1/link/token/create with a non-sandbox key (400, INVALID_REQUEST).
RATE_LIMIT_EXCEEDEDThe key went over its per-minute limit; wait the number of seconds in the Retry-After header (429, RATE_LIMIT_EXCEEDED).
PRODUCTION_NOT_ENABLEDA production key was used; production keys can be created but stay inert in this sandbox build (403, INVALID_REQUEST).
INVALID_WEBHOOK_EVENTAn unknown event type was named — use one of the 10 webhook events (400, INVALID_INPUT).
PROTECTED_KEYDashboard only: the shared public demo key cannot be rotated, re-limited or revoked (409, INVALID_REQUEST).
INSUFFICIENT_ROLEDashboard only: your dashboard role cannot change platform settings (403, INVALID_REQUEST).
branding.sourceorganization · preview · default

Webhooks

We POST signed events to your endpoint. Verify Silkroute-Signature: sha256=… (HMAC-SHA256 over the raw body). Failed deliveries retry with backoff.

TRANSACTIONS_SYNCED

New transactions are available for an Item.

ITEM_LOGIN_REQUIRED

The user must re-authenticate the Item.

PAYMENT_STATUS_UPDATED

A payment changed state (executing → settled/failed).

Sandbox credentials

FieldValue
client_idclient_demo_5f3a9c
secretsandbox-secret-DEMO-do-not-use
user_refenduser_demo_001
Sandbox banksins_tumaris, ins_lochin, ins_bulut (fictional)
Any bank loginworks except user_bad
OTP code123456
Force rejectioncreditor account ending 0000

Error codes

Errors use the shape { error_type, error_code, error_message, display_message, request_id }.

error_code
INVALID_CODE_VERIFIERPKCE: code_verifier missing or does not match the link_token’s code_challenge.
INVALID_LOGIN_SESSIONThe OTP step came from another Link session or the login expired.
RATE_LIMIT_EXCEEDEDToo many requests; retry after the Retry-After header.
ASSISTANT_DAILY_LIMITThe user has used today’s assistant questions.
INVALID_API_KEYSclient_id or secret is wrong (401).
INVALID_LINK_TOKENlink_token is invalid, expired or used.
INVALID_ACCESS_TOKENaccess_token does not resolve to an Item.
ITEM_LOGIN_REQUIREDThe Item needs re-authentication.
INVALID_MFAThe one-time code was incorrect.
INSUFFICIENT_FUNDSDebtor account cannot cover the payment.
CURRENCY_MISMATCHPayment currency ≠ account currency.