This guide connects the authenticated Servix OHLC endpoint to a real candlestick chart built with lightweight-charts v5. The browser calls only your own same-origin backend. Your backend validates the requested asset and interval, adds X-API-Key, applies a timeout and bounded retry policy, and returns only the chart data your product needs.
The examples below use placeholders only. They do not contain a current or historical market observation. Keep the API Key in a server-side secret store; never place it in JavaScript delivered to a browser, a URL, public HTML, analytics, logs, or source control.
Check reliable history before requesting candles
Call the authenticated GET /api/v1/assets/supported catalog first. For the selected asset, read ohlcAvailable and ohlcAvailableFrom. Do not show a chart action when availability is false, and never choose a from earlier than the returned boundary.
{
"code": "USD_RLS",
"ohlcAvailable": true,
"ohlcAvailableFrom": "<source-timestamp>"
}
Reliable history starts independently for each asset. Older records from legacy collection cannot manufacture trustworthy earlier candles, so Servix does not synthesize or backfill data before ohlcAvailableFrom. A request that crosses that boundary returns OHLC_BEFORE_AVAILABLE_HISTORY and includes the valid availableFrom.
Understand the OHLC contract
| Rule | Behavior |
|---|---|
| Intervals | Exactly 15m, 1h, 4h, and 1d. |
| Time | from, to, candle start, and candle end are UTC timestamps. |
| Range | The request is half-open: [from, to). Candles are aligned to UTC interval boundaries and returned in ascending order. |
| Missing observations | An empty bucket is omitted. The API does not insert zeroes or carry the previous close forward. |
| Current candle | complete=false means the candle is still forming or only partly covered by the requested window. |
| Corrections | The latest accepted correction wins before aggregation. A later request can therefore revise a previously returned candle. |
| Precision | OHLC values are emitted as plain JSON numbers without scientific notation. Do not round them before your presentation boundary. |
The maximum range is 31 days for 15m, 180 days for 1h, and 730 days for 4h. The 1d interval may use the retained reliable history. Every request is also capped at 5,000 buckets and consumes one normal asset quota unit after successful authentication.
Try one authenticated request with cURL
Run this only from a trusted terminal or backend environment. Replace every angle-bracket placeholder locally:
curl --fail-with-body \
--header 'Accept: application/json' \
--header 'X-API-Key: <your-api-key>' \
'https://servix.cc/api/v1/assets/USD_RLS/ohlc?interval=15m&from=<UTC-from>&to=<UTC-to>'
A safe public contract example keeps the protected values redacted. The observation count of 1 is a clearly synthetic integer:
{
"code": "USD_RLS",
"baseCode": "USD",
"labelEn": "US Dollar / Iranian Rial",
"labelFa": "دلار آمریکا / ریال ایران",
"quoteUnit": "RLS",
"interval": "15m",
"from": "<UTC-from>",
"to": "<UTC-to>",
"availableFrom": "<source-timestamp>",
"candles": [{
"start": "<bucket-start>",
"end": "<bucket-end>",
"open": "<protected-market-value>",
"high": "<protected-market-value>",
"low": "<protected-market-value>",
"close": "<protected-market-value>",
"observationCount": 1,
"complete": false
}]
}
Put a small Node and TypeScript proxy in front of Servix
This Express-style handler accepts only product-owned asset and interval allowlists. It normalizes UTC timestamps, keeps the API Key in process.env, uses a six-second timeout, retries only network and selected transient server failures, and caches successful responses briefly. Production applications with more than one process should use a shared backend cache.
import type { Request, Response } from 'express'
const API_BASE_URL = 'https://servix.cc'
const ASSETS = new Set(['USD_RLS', 'GOLD_18_RLS'])
const INTERVALS = new Set(['15m', '1h', '4h', '1d'])
const RETRYABLE = new Set([502, 503, 504])
const SAFE_UPSTREAM_ERRORS = new Set([
'OHLC_UNSUPPORTED_INTERVAL', 'OHLC_INVALID_RANGE', 'OHLC_RANGE_TOO_LARGE',
'OHLC_BUCKET_LIMIT_EXCEEDED', 'OHLC_BEFORE_AVAILABLE_HISTORY', 'OHLC_UNAVAILABLE',
])
const MAX_CACHE_ENTRIES = 200
type AuthenticatedRequest = Request & {
user?: { id: string; canReadMarketData: boolean }
}
type Candle = {
start: string; end: string
open: number; high: number; low: number; close: number
observationCount: number; complete: boolean
}
type OhlcPayload = {
code: string; baseCode: string; labelEn: string; labelFa: string
quoteUnit: string; interval: string; from: string; to: string
availableFrom: string; candles: Candle[]
}
const cache = new Map<string, { expiresAt: number; payload: OhlcPayload }>()
class ProxyError extends Error {
constructor(
readonly status: number,
readonly code: string,
readonly availableFrom?: string,
) { super(code) }
}
function utc(value: unknown): string | null {
if (typeof value !== 'string' || value.length > 40
|| !/(Z|[+-]\d{2}:\d{2})$/.test(value)) return null
const parsed = new Date(value)
return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString()
}
function object(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown> : null
}
function requiredText(source: Record<string, unknown>, key: string): string {
const value = source[key]
if (typeof value !== 'string' || !value.trim()) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
return value
}
function readOhlc(input: unknown, assetCode: string, interval: string): OhlcPayload {
const source = object(input)
if (!source || source.code !== assetCode || source.interval !== interval
|| !Array.isArray(source.candles)) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
let previousStart = -Infinity
const candles = source.candles.map((inputCandle): Candle => {
const candle = object(inputCandle)
if (!candle) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
const start = utc(candle.start)
const end = utc(candle.end)
const values = [candle.open, candle.high, candle.low, candle.close]
const observationCount = candle.observationCount
const startTime = start ? Date.parse(start) : NaN
if (!start || !end || start >= end || startTime <= previousStart
|| !values.every(value => typeof value === 'number' && Number.isFinite(value))
|| Number(candle.high) < Math.max(Number(candle.open), Number(candle.close))
|| Number(candle.low) > Math.min(Number(candle.open), Number(candle.close))
|| typeof observationCount !== 'number' || !Number.isInteger(observationCount)
|| observationCount < 1
|| typeof candle.complete !== 'boolean') {
throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
}
previousStart = startTime
return {
start, end,
open: Number(candle.open), high: Number(candle.high),
low: Number(candle.low), close: Number(candle.close),
observationCount, complete: candle.complete,
}
})
const from = utc(source.from)
const to = utc(source.to)
const availableFrom = utc(source.availableFrom)
if (!from || !to || !availableFrom) throw new ProxyError(502, 'INVALID_OHLC_RESPONSE')
return {
code: assetCode,
baseCode: requiredText(source, 'baseCode'),
labelEn: requiredText(source, 'labelEn'),
labelFa: requiredText(source, 'labelFa'),
quoteUnit: requiredText(source, 'quoteUnit'),
interval, from, to, availableFrom, candles,
}
}
function remember(cacheKey: string, payload: OhlcPayload): void {
if (cache.size >= MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next()
if (!oldest.done) cache.delete(oldest.value)
}
cache.set(cacheKey, { expiresAt: Date.now() + 20_000, payload })
}
async function requestOhlc(
url: URL, apiKey: string, assetCode: string, interval: string,
): Promise<OhlcPayload> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let response: globalThis.Response
try {
response = await fetch(url, {
headers: { Accept: 'application/json', 'X-API-Key': apiKey },
redirect: 'error',
signal: AbortSignal.timeout(6_000),
})
} catch {
if (attempt === 3) throw new ProxyError(503, 'UPSTREAM_UNAVAILABLE')
await new Promise(resolve => setTimeout(resolve, attempt * 250))
continue
}
if (RETRYABLE.has(response.status) && attempt < 3) {
await new Promise(resolve => setTimeout(resolve, attempt * 250))
continue
}
const body: unknown = await response.json().catch(() => null)
if (response.ok) return readOhlc(body, assetCode, interval)
const problem = object(body)
const candidateCode = typeof problem?.code === 'string' ? problem.code : ''
const code = SAFE_UPSTREAM_ERRORS.has(candidateCode)
? candidateCode : 'OHLC_REQUEST_FAILED'
const availableFrom = utc(problem?.availableFrom)
if (response.status === 400 && code === 'OHLC_BEFORE_AVAILABLE_HISTORY' && availableFrom) {
throw new ProxyError(400, code, availableFrom)
}
const safeStatus = response.status === 404 || response.status === 429
? response.status : response.status === 400 ? 400 : 502
throw new ProxyError(safeStatus, code)
}
throw new ProxyError(503, 'UPSTREAM_UNAVAILABLE')
}
export async function ohlcChartData(
req: AuthenticatedRequest, res: Response,
): Promise<void> {
res.set('Cache-Control', 'private, no-store')
if (!req.user) {
res.status(401).json({ code: 'AUTHENTICATION_REQUIRED' })
return
}
if (!req.user.canReadMarketData) {
res.status(403).json({ code: 'MARKET_DATA_ACCESS_DENIED' })
return
}
const assetCode = String(req.query.assetCode ?? '').toUpperCase()
const interval = String(req.query.interval ?? '')
const from = utc(req.query.from)
const to = utc(req.query.to)
if (!ASSETS.has(assetCode) || !INTERVALS.has(interval) || !from || !to || from >= to) {
res.status(400).json({ code: 'INVALID_CHART_REQUEST' })
return
}
const apiKey = process.env.SERVIX_API_KEY?.trim()
if (!apiKey) {
res.status(503).json({ code: 'CHART_DATA_UNAVAILABLE' })
return
}
const cacheKey = [assetCode, interval, from, to].join('|')
const cached = cache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.payload)
return
}
const url = new URL('/api/v1/assets/' + assetCode + '/ohlc', API_BASE_URL)
url.search = new URLSearchParams({ interval, from, to }).toString()
try {
const payload = await requestOhlc(url, apiKey, assetCode, interval)
remember(cacheKey, payload)
res.json(payload)
} catch (error) {
const failure = error instanceof ProxyError
? error : new ProxyError(503, 'CHART_DATA_UNAVAILABLE')
res.status(failure.status).json({
code: failure.status === 429 ? 'CHART_QUOTA_EXHAUSTED' : failure.code,
...(failure.availableFrom ? { availableFrom: failure.availableFrom } : {}),
})
}
}
Validate the successful upstream schema before caching in production: require the selected code and interval, timezone-aware and ascending candle times, finite numeric OHLC fields, high >= open/close >= low, a positive integer observationCount, and a boolean complete. Return safe application errors to the browser and never forward credentials, upstream response bodies, or market values into logs.
Render and update the chart with Lightweight Charts v5
Install lightweight-charts v5 in your browser application. In v5, create the candlestick series with chart.addSeries(CandlestickSeries, options). The factory below switches intervals, keeps gaps as gaps, updates the newest candle with series.update, and releases every browser resource.
import {
CandlestickSeries,
ColorType,
createChart,
type CandlestickData,
type UTCTimestamp,
} from 'lightweight-charts'
type Interval = '15m' | '1h' | '4h' | '1d'
type ApiCandle = {
start: string
end: string
open: number
high: number
low: number
close: number
observationCount: number
complete: boolean
}
function chartCandle(candle: ApiCandle): CandlestickData<UTCTimestamp> {
const hasZone = (value: unknown): value is string =>
typeof value === 'string' && /(Z|[+-]\d{2}:\d{2})$/.test(value)
const startTime = hasZone(candle.start) ? Date.parse(candle.start) : NaN
const endTime = hasZone(candle.end) ? Date.parse(candle.end) : NaN
const time = Math.floor(startTime / 1000)
const values = [candle.open, candle.high, candle.low, candle.close]
if (!Number.isFinite(time) || !Number.isFinite(endTime) || startTime >= endTime
|| !values.every(value => typeof value === 'number' && Number.isFinite(value))
|| candle.high < Math.max(candle.open, candle.close)
|| candle.low > Math.min(candle.open, candle.close)
|| !Number.isInteger(candle.observationCount) || candle.observationCount < 1
|| typeof candle.complete !== 'boolean') {
throw new Error('Invalid OHLC candle.')
}
return {
time: time as UTCTimestamp,
open: Number(candle.open),
high: Number(candle.high),
low: Number(candle.low),
close: Number(candle.close),
}
}
function chartCandles(candles: ApiCandle[]): CandlestickData<UTCTimestamp>[] {
const points = candles.map(chartCandle)
for (let index = 1; index < points.length; index += 1) {
if (points[index].time <= points[index - 1].time) {
throw new Error('OHLC candles must be strictly ascending.')
}
}
return points
}
function readCandles(input: unknown): ApiCandle[] {
if (typeof input !== 'object' || input === null || !('candles' in input)
|| !Array.isArray(input.candles)) throw new Error('Invalid OHLC response.')
const candles = input.candles as ApiCandle[]
chartCandles(candles)
return candles
}
export function mountOhlcChart(
container: HTMLElement,
status: HTMLElement,
assetCode: string,
availableFrom: string,
precision: number,
) {
const chart = createChart(container, {
height: 420,
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#334155',
attributionLogo: true,
},
timeScale: { timeVisible: true, secondsVisible: false },
})
const series = chart.addSeries(CandlestickSeries, {
upColor: '#059669', downColor: '#dc4c64', borderVisible: false,
wickUpColor: '#059669', wickDownColor: '#dc4c64',
priceFormat: { type: 'price', precision, minMove: 10 ** -precision },
})
let interval: Interval = '1h'
let request: AbortController | null = null
let refreshRequest: AbortController | null = null
let latest: ApiCandle | null = null
let destroyed = false
async function load(nextInterval: Interval): Promise<void> {
request?.abort()
const activeRequest = new AbortController()
request = activeRequest
interval = nextInterval
status.textContent = 'Loading chart…'
try {
const boundary = Date.parse(availableFrom)
if (!Number.isFinite(boundary)) throw new Error('Invalid history boundary.')
const to = new Date().toISOString()
const from = new Date(Math.max(
boundary, Date.now() - 14 * 24 * 60 * 60 * 1000,
)).toISOString()
const query = new URLSearchParams({ assetCode, interval, from, to })
const response = await fetch('/api/chart-data?' + query, {
credentials: 'same-origin', signal: activeRequest.signal,
})
if (!response.ok) throw new Error('Chart data is unavailable.')
const candles = readCandles(await response.json())
if (destroyed || request !== activeRequest) return
// Missing buckets stay missing: do not insert zeroes or carry a close forward.
series.setData(chartCandles(candles))
latest = candles.at(-1) ?? null
status.textContent = latest?.complete ? 'Up to date' : 'Latest candle is forming'
chart.timeScale().fitContent()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!destroyed) status.textContent = 'Chart data is unavailable.'
} finally {
if (request === activeRequest) request = null
}
}
async function refreshLatest(): Promise<void> {
if (!latest || destroyed || document.hidden) return
refreshRequest?.abort()
const activeRefresh = new AbortController()
refreshRequest = activeRefresh
try {
const query = new URLSearchParams({
assetCode, interval, from: latest.start, to: new Date().toISOString(),
})
const response = await fetch('/api/chart-data?' + query, {
credentials: 'same-origin', signal: activeRefresh.signal,
})
if (!response.ok) return
const newest = readCandles(await response.json()).at(-1)
if (newest && Date.parse(newest.start) >= Date.parse(latest.start)) {
series.update(chartCandle(newest))
latest = newest
status.textContent = newest.complete ? 'Up to date' : 'Latest candle is forming'
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
// Keep the last validated chart and let the next bounded refresh try again.
} finally {
if (refreshRequest === activeRefresh) refreshRequest = null
}
}
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry || destroyed) return
chart.resize(Math.floor(entry.contentRect.width), 420)
})
resizeObserver.observe(container)
const onVisibilityChange = () => {
if (document.hidden) refreshRequest?.abort()
else void refreshLatest()
}
document.addEventListener('visibilitychange', onVisibilityChange)
const refreshTimer = window.setInterval(() => void refreshLatest(), 30_000)
void load(interval)
return {
setInterval(nextInterval: Interval) { void load(nextInterval) },
destroy() {
destroyed = true
request?.abort()
refreshRequest?.abort()
window.clearInterval(refreshTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
resizeObserver.disconnect()
chart.remove()
},
}
}
Wire four buttons to setInterval('15m'), setInterval('1h'), setInterval('4h'), and setInterval('1d'). Choose precision and minMove from your product's instrument configuration. JavaScript number is suitable for chart rendering, but exact accounting or settlement calculations should retain decimal values in a decimal-aware backend or parser. Do not call toFixed on the API payload before rendering.
series.update is appropriate for the latest candle or the next new candle. When you refresh an older window to pick up a correction, validate the complete response and call setData with the ascending series. Do not use an incomplete candle as a final close.
Handle errors without retry storms
| Result | What your backend should do |
|---|---|
OHLC_UNSUPPORTED_INTERVAL or OHLC_INVALID_RANGE | Fix the allowlisted input. Do not retry the same request. |
OHLC_RANGE_TOO_LARGE or OHLC_BUCKET_LIMIT_EXCEEDED | Shorten the requested window before trying again. |
OHLC_BEFORE_AVAILABLE_HISTORY | Use the returned availableFrom as the earliest boundary. |
OHLC_UNAVAILABLE or 404 | Show a non-zero, explicit unavailable state for that asset. |
| 401 or 403 | Fix server-side credentials or account access. Never ask the browser for the API Key. |
| 429 | Stop automatic polling until quota becomes available. Do not retry in a loop. |
| 502, 503, or 504 | Use a short timeout, exponential backoff, jitter, and a strict maximum attempt count. |
Keep attribution and lifecycle behavior accurate
Lightweight Charts requires TradingView creator attribution; retain the attribution logo or provide the notice and link required by its license. Separately, call the authenticated GET /api/v1/access endpoint and follow the returned attribution requirement for Servix market data. These are two different obligations.
Cache identical OHLC windows in your backend to protect quota, but keep the TTL short for an incomplete edge candle. Key the cache by asset, interval, from, and to. Abort superseded interval requests, disconnect ResizeObserver, clear polling timers, and call chart.remove() when the component unmounts.
Continue with customer API documentation, API-key authentication, supported assets, and errors and quotas. Create an account or compare plans and daily limits.