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 │ │
Step 1: Create Link Token
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
| Scenario | Include access_token? | isAuthorized | User Flow (UI) | Returns public_token |
|---|---|---|---|---|
| First-time user connecting to Kryptos | No | false | CONNECT → INTEGRATION (account created) | Yes |
| User doesn't have an access token yet | No | false | CONNECT → INTEGRATION (account created) | Yes |
| Returning user with stored access token | Yes | true | INTEGRATION only (account already exists) | No |
| Adding more integrations for user | Yes | true | INTEGRATION only (account already exists) | No |
Implementation Examples
- JavaScript
- Python
- PHP
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" });
}
});
from flask import Flask, request, jsonify
import requests
import os
app = Flask(__name__)
KRYPTOS_BASE_URL = "https://connect-api.kryptos.io"
@app.route("/api/kryptos/create-link-token", methods=["POST"])
def create_link_token():
try:
# Check if user has an existing access token stored
existing_access_token = get_user_access_token(request.user_id)
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 existing_access_token:
payload["access_token"] = existing_access_token
response = requests.post(
f"{KRYPTOS_BASE_URL}/link-token",
json=payload,
headers={
"Content-Type": "application/json",
"X-Client-Id": os.getenv("KRYPTOS_CLIENT_ID"),
"X-Client-Secret": os.getenv("KRYPTOS_CLIENT_SECRET"),
},
)
data = response.json()["data"]
# Return format expected by Web SDK's generateLinkToken
return jsonify({
"link_token": data["link_token"],
"isAuthorized": bool(existing_access_token),
})
except Exception as e:
return jsonify({"error": "Failed to create link token"}), 500
<?php
header('Content-Type: application/json');
$KRYPTOS_BASE_URL = 'https://connect-api.kryptos.io';
// Check if user has an existing access token stored
$existingAccessToken = getUserAccessToken($userId);
$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;
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $KRYPTOS_BASE_URL . '/link-token',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Client-Id: ' . getenv('KRYPTOS_CLIENT_ID'),
'X-Client-Secret: ' . getenv('KRYPTOS_CLIENT_SECRET'),
],
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
// Return format expected by Web SDK's generateLinkToken
echo json_encode([
'link_token' => $result['data']['link_token'],
'isAuthorized' => !empty($existingAccessToken),
]);
?>
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
- JavaScript
- Python
- PHP
// 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" });
}
});
@app.route("/api/kryptos/exchange-token", methods=["POST"])
def exchange_token():
try:
public_token = request.json.get("public_token")
response = requests.post(
f"{KRYPTOS_BASE_URL}/token/exchange",
json={"public_token": public_token},
headers={
"Content-Type": "application/json",
"X-Client-Id": os.getenv("KRYPTOS_CLIENT_ID"),
"X-Client-Secret": os.getenv("KRYPTOS_CLIENT_SECRET"),
},
)
data = response.json()["data"]
# Store tokens securely for the user
save_user_tokens(
request.user_id,
access_token=data["access_token"],
grant_id=data["grant_id"],
workspace_id=data["workspace_id"],
)
return jsonify({"success": True})
except Exception as e:
return jsonify({"error": "Failed to exchange token"}), 500
<?php
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
$publicToken = $input['public_token'];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://connect-api.kryptos.io/token/exchange',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['public_token' => $publicToken]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Client-Id: ' . getenv('KRYPTOS_CLIENT_ID'),
'X-Client-Secret: ' . getenv('KRYPTOS_CLIENT_SECRET'),
],
]);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response, true);
$data = $result['data'];
// Store tokens securely for the user
saveUserTokens($userId, [
'access_token' => $data['access_token'],
'grant_id' => $data['grant_id'],
'workspace_id' => $data['workspace_id'],
]);
echo json_encode(['success' => true]);
?>
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"
}
}
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.
First Connection (New User):
- Frontend →
generateLinkToken()returns{ link_token, isAuthorized: false } - SDK Flow: INIT → CONNECT → INTEGRATION → STATUS
- User authenticates and connects integrations
onSuccessreceives{ public_token: "..." }- Backend exchanges
public_tokenforaccess_token - IMPORTANT: Store
access_tokenin database for future use
Subsequent Connections (Returning User):
- Frontend →
generateLinkToken()returns{ link_token, isAuthorized: true } - SDK Flow: INIT → INTEGRATION → STATUS (authentication skipped)
- User connects more integrations
onSuccessreceivesnull(no new token needed)- 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"
}
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:
| Mode | What it does |
|---|---|
latest | Incremental refresh — re-pull from where the last sync left off. Wallets/exchanges only. |
from_start | Wipe 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_startis supported. Returns409 CSV_RESYNC_LATEST_UNSUPPORTEDformode: "latest"(CSVs have no incremental concept). The samewalletIdis 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:
| Status | Code | Description |
|---|---|---|
| 401 | - | Invalid client credentials. |
| 403 | GRANT_NOT_FOUND | The user has not granted your client access (or revoked it). |
| 404 | INTEGRATION_NOT_FOUND | No wallet with this walletId exists for the user. |
| 404 | CSV_SOURCE_MISSING | CSV resync requested but no original file is on record. |
| 409 | CSV_RESYNC_LATEST_UNSUPPORTED | mode: "latest" is not valid for CSVs — use "from_start" instead. |
The endpoint returns 202 immediately and the resync runs in the background. To observe progress:
- Poll the user's integrations list to watch
key.statustransition fromSYNCINGback toCOMPLETED. - For CSV resyncs, the response includes a
job_idyou can poll for finer-grained status. - Listen for the
integration.updatedandintegration.failedwebhook 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:
| Parameter | Type | Description |
|---|---|---|
grantId | string | The grant ID returned from token exchange (e.g. cgrant_abc123). |
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
transactionLimit | number | No* | Positive integer. Per-user limit (not capped). |
enableLimiter | boolean | No* | 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:
| Status | Code | Description |
|---|---|---|
| 400 | NOT_ANON_USER | User is not a Guest account. Limits only apply to Guest users. |
| 401 | - | Invalid client credentials. |
| 403 | ACCESS_DENIED | Grant does not belong to this client. |
| 404 | GRANT_NOT_FOUND | No grant found with this ID. |
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 Code | Description | Solution |
|---|---|---|
INVALID_CLIENT | Invalid client credentials | Verify client_id and client_secret |
INVALID_TOKEN | Token expired or invalid | Re-authenticate user |
TOKEN_EXPIRED | Link or public token expired | Create new link token |
TOKEN_ALREADY_USED | Token was already consumed | Create new link token |
INVALID_SCOPE | Requested scope not allowed | Check allowed scopes for your client |
INVALID_GRANT | Grant revoked or invalid | Re-authenticate user |
INSUFFICIENT_PERMISSIONS | User lacks required role | User must be owner/admin |
WORKSPACE_NOT_FOUND | Workspace doesn't exist | Verify 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
- Web SDK and Mobile SDK for frontend integration
- Examples for complete end-to-end examples
- API Endpoints for data API reference