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://connect.kryptos.io/api/v1/holdings",
{
headers: {
Authorization: `Bearer ${accessToken}`,
"X-Client-Id": process.env.KRYPTOS_CLIENT_ID,
"X-Client-Secret": process.env.KRYPTOS_CLIENT_SECRET,
},
},
);

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. Wallets/exchanges only.
from_startWipe and re-pull from scratch. Works for wallets, exchanges, and CSVs.

Behavior by integration type:

  • Wallet/exchange — both modes are supported. The resync runs asynchronously on the Kryptos sync workers.
  • CSV — only from_start is supported. Returns 409 CSV_RESYNC_LATEST_UNSUPPORTED for mode: "latest" (CSVs have no incremental concept). The same walletId is reused; the underlying handler atomically wipes the wallet's data and re-ingests from the originally uploaded file in one step.

Response (202 — wallet/exchange):

{
"success": true,
"data": {
"mode": "latest",
"integration_type": "wallet",
"wallet_id": "wallet-uuid",
"status": "queued"
}
}

Response (202 — CSV from_start):

{
"success": true,
"data": {
"mode": "from_start",
"integration_type": "csv",
"wallet_id": "wallet-uuid",
"job_id": "job_xyz789",
"status": "ongoing"
}
}

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.
404CSV_SOURCE_MISSINGCSV resync requested but no original file is on record.
409CSV_RESYNC_LATEST_UNSUPPORTEDmode: "latest" is not valid for CSVs — use "from_start" instead.
Asynchronous behavior

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

  • Poll the user's integrations list to watch key.status transition from SYNCING back to COMPLETED.
  • For CSV resyncs, the response includes a job_id you can poll for finer-grained status.
  • Listen for the integration.updated and integration.failed webhook events.

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

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