Skip to main content

Backend Implementation

This guide covers the server-side implementation required for Kryptos Connect. Your backend is responsible for securely creating link tokens, exchanging public tokens for long-lived access tokens, managing user sessions, and making authenticated API calls on behalf of your users.

Architecture​

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│ Client │ │ Your │ │ Kryptos │
│ App │ │ Backend │ │ Connect │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. Request link token │ │
├──────────────────────►│ │
│ │ 2. Create link token │
│ ├──────────────────────►│
│ │ │
│ │◄──────────────────────┤
│ │ link_token │
│◄──────────────────────┤ │
│ link_token │ │
│ │ │
│ 3. Open widget │ │
├───────────────────────┼──────────────────────►│
│ │ User authenticates │
│ │ and grants consent │
│◄──────────────────────┼───────────────────────┤
│ public_token │ │
│ │ │
│ 4. Exchange token │ │
├──────────────────────►│ │
│ │ 5. Exchange token │
│ ├──────────────────────►│
│ │ │
│ │◄──────────────────────┤
│ │ access_token (15 yr) │
│◄──────────────────────┤ grant_id │
│ Success │ │

Create a link token endpoint on your backend that the Web SDK's generateLinkToken function will call. The implementation depends on whether the user is new or returning:

Endpoint: POST https://connect-api.kryptos.io/link-token

Authentication: Client credentials via X-Client-Id and X-Client-Secret headers

Choosing the Right Approach​

ScenarioInclude access_token?isAuthorizedUser Flow (UI)Returns public_token
First-time user connecting to KryptosNofalseCONNECT → INTEGRATION (account created)Yes
User doesn't have an access token yetNofalseCONNECT → INTEGRATION (account created)Yes
Returning user with stored access tokenYestrueINTEGRATION only (account already exists)No
Adding more integrations for userYestrueINTEGRATION only (account already exists)No

Implementation Examples​

const express = require("express");
const axios = require("axios");

const app = express();
app.use(express.json());

const KRYPTOS_BASE_URL = "https://connect-api.kryptos.io";

// Endpoint for the Web SDK's generateLinkToken function
app.post("/api/kryptos/create-link-token", async (req, res) => {
try {
// Check if user has an existing access token stored
const existingAccessToken = await getUserAccessToken(req.user?.id);

const payload = {
scopes:
"openid profile offline_access email portfolios:read transactions:read integrations:read tax:read accounting:read reports:read workspace:read users:read",
};

// If user has existing token, include it to skip authentication
if (existingAccessToken) {
payload.access_token = existingAccessToken;
}

const response = await axios.post(
`${KRYPTOS_BASE_URL}/link-token`,
payload,
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

// Return format expected by Web SDK's generateLinkToken
res.json({
link_token: response.data.data.link_token,
isAuthorized: !!existingAccessToken, // true = skip auth, false = full flow
});
} catch (error) {
res.status(500).json({ error: "Failed to create link token" });
}
});

Your Backend Response (for Web SDK):

{
"link_token": "link_abc123xyz789",
"isAuthorized": false
}

Kryptos API Response (Fresh Session):

{
"success": true,
"data": {
"link_token": "link_abc123xyz789",
"expires_at": "2024-01-28T10:30:00Z"
}
}

Kryptos API Response (Session Resumed with access_token):

{
"success": true,
"data": {
"link_token": "link_abc123xyz789",
"expires_at": "2024-01-28T10:30:00Z",
"user_id": "uuid-user-123",
"workspace_id": "uuid-workspace-456",
"has_existing_grant": true
}
}

Step 2: Exchange Public Token​

Create an endpoint to exchange the public token for a long-lived access token. The Web SDK's onSuccess callback will send the public_token to this endpoint.

Endpoint: POST https://connect-api.kryptos.io/token/exchange

Authentication: Client credentials via headers

// Endpoint for the Web SDK's onSuccess callback
app.post("/api/kryptos/exchange-token", async (req, res) => {
try {
const { public_token } = req.body;

const response = await axios.post(
`${KRYPTOS_BASE_URL}/token/exchange`,
{ public_token },
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

const { access_token, grant_id, workspace_id } = response.data.data;

// Store tokens securely for the user
await saveUserTokens(req.user.id, {
access_token,
grant_id,
workspace_id,
});

res.json({ success: true });
} catch (error) {
res.status(500).json({ error: "Failed to exchange token" });
}
});

Response:

{
"success": true,
"data": {
"access_token": "cat_abc123xyz789",
"grant_id": "cgrant_abc123xyz789",
"token_type": "Bearer",
"expires_in": 473040000,
"scope": "openid profile offline_access email portfolios:read transactions:read integrations:read tax:read accounting:read reports:read workspace:read users:read",
"workspace_id": "uuid-workspace-123"
}
}
Long-Lived Access Tokens

Access tokens are valid for 15 years (473,040,000 seconds). No refresh tokens are needed. Store the grant_id to allow users to revoke access later.

Complete Integration Flow

First Connection (New User):

  1. Frontend → generateLinkToken() returns { link_token, isAuthorized: false }
  2. SDK Flow: INIT → CONNECT → INTEGRATION → STATUS
  3. User authenticates and connects integrations
  4. onSuccess receives { public_token: "..." }
  5. Backend exchanges public_token for access_token
  6. IMPORTANT: Store access_token in database for future use

Subsequent Connections (Returning User):

  1. Frontend → generateLinkToken() returns { link_token, isAuthorized: true }
  2. SDK Flow: INIT → INTEGRATION → STATUS (authentication skipped)
  3. User connects more integrations
  4. onSuccess receives null (no new token needed)
  5. Integrations are added to user's existing account

Step 3: Make API Calls​

Use the access token to call Kryptos APIs:

async function getUserHoldings(accessToken) {
const response = await axios.get("https://api-v2.kryptos.io/v1/holdings", {
headers: { Authorization: `Bearer ${accessToken}` },
});

return response.data;
}

Session Management​

Resume Existing Session​

Pass an existing access token when creating a link token to skip the authentication flow for returning users:

async function createLinkTokenWithSession(userId, existingAccessToken) {
const response = await axios.post(
"https://connect-api.kryptos.io/link-token",
{
scopes:
"openid profile offline_access email portfolios:read transactions:read integrations:read tax:read accounting:read reports:read workspace:read users:read",
access_token: existingAccessToken, // Pre-authenticate with existing token
},
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data.data;
}

Response with existing session:

{
"success": true,
"data": {
"link_token": "link_xxx...",
"expires_at": "...",
"user_id": "user123",
"workspace_id": "ws123",
"has_existing_grant": true
}
}

Check Session Status​

Check if a user has an active session before opening the widget:

async function checkSession(accessToken) {
const response = await axios.post(
"https://connect-api.kryptos.io/link-token/check-session",
{
access_token: accessToken,
},
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data.data;
}

Response (Valid Session):

{
"success": true,
"data": {
"has_valid_session": true,
"user_id": "uuid-user-123",
"workspace_id": "uuid-workspace-456",
"workspace_name": "My Workspace",
"granted_scopes": "openid profile offline_access email portfolios:read transactions:read integrations:read tax:read accounting:read reports:read workspace:read users:read",
"grant_id": "cgrant_abc123"
}
}

Response (No Valid Session):

{
"success": true,
"data": {
"has_valid_session": false,
"reason": "token_expired"
}
}

Revoke Grant​

Allow users to disconnect their account by revoking the grant:

Endpoint: POST https://connect-api.kryptos.io/token/revoke

async function revokeGrant(grantId) {
const response = await axios.post(
"https://connect-api.kryptos.io/token/revoke",
{
grant_id: grantId,
},
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data;
}

Response:

{
"success": true,
"message": "Grant revoked successfully"
}
Grant Revocation

When a grant is revoked, all associated access tokens are immediately invalidated. Any subsequent API calls using those tokens will fail.

List Connected Grants​

Get all active grants for your client:

Endpoint: GET https://connect-api.kryptos.io/token/grants

async function listGrants() {
const response = await axios.get(
"https://connect-api.kryptos.io/token/grants",
{
headers: {
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data.data;
}

Response:

{
"success": true,
"data": {
"grants": [
{
"grant_id": "cgrant_abc123xyz789",
"workspace_id": "uuid-workspace-123",
"scopes": "transactions:read balances:read",
"created_at": "2024-01-15T10:00:00Z",
"expires_at": "2039-01-15T10:00:00Z"
}
],
"count": 1
}
}

Resync Integration​

Trigger a resync on a connected user's wallet, exchange, or CSV integration. Useful when you want to re-pull recent transactions on demand (e.g. a "Refresh" button in your app) or fully re-ingest after a data change.

Endpoint: POST https://connect-api.kryptos.io/developer/integrations/{walletId}/resync

async function resyncIntegration(walletId, userId, mode = "latest") {
const response = await axios.post(
`https://connect-api.kryptos.io/developer/integrations/${walletId}/resync`,
{
user_id: userId,
mode, // "latest" or "from_start"
},
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data;
}

Modes:

ModeWhat it does
latestIncremental refresh — re-pull from where the last sync left off.
from_startWipe this integration's data and re-pull everything.

Behavior by integration type:

  • Wallet/exchange — both modes are supported. The resync runs asynchronously on the Kryptos sync workers.
  • CSV — not supported in either mode. latest returns 409 CSV_RESYNC_LATEST_UNSUPPORTED and from_start returns 409 CSV_RESYNC_UNSUPPORTED. To re-import a CSV, upload the file again through the SDK — there is nothing to re-fetch from.

Response (202):

{
"success": true,
"data": {
"mode": "latest",
"integration_type": "wallet",
"wallet_id": "wallet-uuid",
"job_id": "sync_a1b2c3d4e5f6",
"status": "queued",
"note": "Resync runs asynchronously. Poll GET /v1/sync/{job_id}, or GET /v1/integrations to observe status transitioning through SYNCING → COMPLETED."
}
}

job_id is the sync id — poll it at GET /v1/sync/{job_id} with the user's access token.

Errors:

StatusCodeDescription
401-Invalid client credentials.
403GRANT_NOT_FOUNDThe user has not granted your client access (or revoked it).
404INTEGRATION_NOT_FOUNDNo wallet with this walletId exists for the user.
409CSV_RESYNC_LATEST_UNSUPPORTEDmode: "latest" is not valid for a CSV integration.
409CSV_RESYNC_UNSUPPORTEDCSV integrations cannot be resynced — upload the file again instead.
409SYNC_DISABLEDSyncing is turned off for this integration.
Asynchronous behavior

The endpoint returns 202 immediately and the resync runs in the background. To observe progress:

  • Poll GET /v1/sync/{job_id} with the user's access token. Wait for a terminal status: completed, partially_synced, failed or cancelled. Note partially_synced is a success with some rows dropped, not a failure.
  • Or poll GET /v1/integrations and watch the integration's latestSync.
  • Or listen for the integration.updated and integration.failed webhook events, which saves you polling entirely.
There are two resync endpoints

This one is a partner endpoint: client credentials, addressed by user_id, for acting on a user's integration from your backend. There is also POST /v1/integrations/{id}/resync, which takes the user's access token and offers the full option set (functions, time windows, price-only resync). Use this one when you hold only client credentials; use /v1 when you hold an access token.

Update Transaction Limit​

Set or update the per-user transaction import limit for a Guest (anonymous) user. Useful for automating billing tier upgrades, free-trial caps, or admin tooling.

Endpoint: PATCH https://connect-api.kryptos.io/developer/grants/{grantId}/transaction-limit

async function updateTransactionLimit(grantId, { transactionLimit, enableLimiter }) {
const response = await axios.patch(
`https://connect-api.kryptos.io/developer/grants/${grantId}/transaction-limit`,
{
transactionLimit,
enableLimiter,
},
{
headers: {
"Content-Type": "application/json",
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

return response.data;
}

// Examples:
// Raise the limit for a user
await updateTransactionLimit("cgrant_abc123", { transactionLimit: 10000 });

// Disable the limiter entirely
await updateTransactionLimit("cgrant_abc123", { enableLimiter: false });

Path Parameters:

ParameterTypeDescription
grantIdstringThe grant ID returned from token exchange (e.g. cgrant_abc123).

Request Body:

FieldTypeRequiredDescription
transactionLimitnumberNo*Positive integer. Per-user limit (not capped).
enableLimiterbooleanNo*Whether the limiter is active for this user.

* At least one field is required.

Response (200):

{
"success": true,
"data": {
"grant_id": "cgrant_abc123xyz789",
"user_id": "anon_user_uid",
"transaction_limit": 10000,
"enable_limiter": true,
"previous_transaction_limit": 1000,
"previous_enable_limiter": true,
"resync": {
"triggered": [
{ "integration_id": "int_8ad611a78678", "sync_id": "sync_1f2e3d4c5b6a" }
],
"failed": [],
"skipped": 0
}
}
}
FieldTypeDescription
transaction_limitnumber | nullThe cap after this call
enable_limiterboolean | nullWhether the limiter is active after this call
previous_transaction_limitnumber | nullThe cap before this call
previous_enable_limiterboolean | nullThe flag before this call
resyncobject | nullSyncs queued by this change; null if nothing changed — see below

previous_transaction_limit and previous_enable_limiter mean the response alone tells you both what the cap became and what it was, with no read-back.

A limit change re-syncs the user's wallets​

Raising a cap does not backfill anything by itself: an incremental sync resumes from the last transaction it wrote, so history a previous sync dropped at the old cap stays missing. So whenever this call actually changes the cap, every re-syncable integration in the workspace is queued for a full re-fetch from the start (resync_from_start — prior data is deleted, then the complete range is read again). You do not need to detect the change and request a resync yourself.

resync is null when the request changed nothing — restating the current values is a no-op. Otherwise:

FieldDescription
triggeredOne entry per queued sync, as { integration_id, sync_id }. Poll GET /v1/sync/{sync_id} for each
failedIntegrations whose sync could not be queued, with the reason
skippedCount of integrations not re-synced: custom wallets, sync-disabled accounts, CSV-fed ones (nothing to re-fetch), and anything past the 100-per-call cap

A non-zero skipped means the fan-out was partial — it is reported so that it never reads as "everything was re-synced".

Reading the resulting cap​

To read a workspace's cap later, call GET /v1/workspaces/{workspace_id} with the user's access token — it requires the workspace:read scope, which is in the default client scope set. Its limits block reports effectiveTransactionLimit (the cap actually enforced), currentTransactionCount and remainingTransactions.

workspace_id comes from the token exchange response, and is also listed per grant by GET /token/grants.

Errors:

StatusCodeDescription
400NOT_ANON_USERUser is not a Guest account. Limits only apply to Guest users.
401-Invalid client credentials.
403ACCESS_DENIEDGrant does not belong to this client.
404GRANT_NOT_FOUNDNo grant found with this ID.
Guest accounts only

Transaction limits only apply to Guest (anonymous) accounts created through your Connect flow. Linked users (who signed in with their existing Kryptos account) manage their own volume and are not subject to developer-imposed limits.

Resolution order: per-user override (this endpoint) → workspace default (Developer Portal) → platform default (100,000, limiter on). The first defined value wins. Setting enableLimiter: false removes the cap entirely for that user.


Error Handling​

Common Errors​

Error CodeDescriptionSolution
INVALID_CLIENTInvalid client credentialsVerify client_id and client_secret
INVALID_TOKENToken expired or invalidRe-authenticate user
TOKEN_EXPIREDLink or public token expiredCreate new link token
TOKEN_ALREADY_USEDToken was already consumedCreate new link token
INVALID_SCOPERequested scope not allowedCheck allowed scopes for your client
INVALID_GRANTGrant revoked or invalidRe-authenticate user
INSUFFICIENT_PERMISSIONSUser lacks required roleUser must be owner/admin
WORKSPACE_NOT_FOUNDWorkspace doesn't existVerify workspace ID

Error Response Format​

{
"success": false,
"error": "Error message",
"code": "ERROR_CODE"
}

Handling Revoked Grants​

Since access tokens are long-lived, the grant may be revoked before the token expires. Handle this case:

async function makeApiCallWithCheck(endpoint, accessToken, grantId) {
try {
return await makeApiCall(endpoint, accessToken);
} catch (error) {
if (error.response?.status === 401) {
// Token or grant invalid - user needs to re-authenticate
throw new Error("Access revoked. Please reconnect your account.");
}
throw error;
}
}

Next Steps​