Skip to main content
Servix Iran market data API
Documentation menu

OHLC chart guide

Build a secure OHLC chart with Lightweight Charts

Build a responsive candlestick chart with the authenticated Servix OHLC API and Lightweight Charts v5 while keeping the API Key on your server.

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

RuleBehavior
IntervalsExactly 15m, 1h, 4h, and 1d.
Timefrom, to, candle start, and candle end are UTC timestamps.
RangeThe request is half-open: [from, to). Candles are aligned to UTC interval boundaries and returned in ascending order.
Missing observationsAn empty bucket is omitted. The API does not insert zeroes or carry the previous close forward.
Current candlecomplete=false means the candle is still forming or only partly covered by the requested window.
CorrectionsThe latest accepted correction wins before aggregation. A later request can therefore revise a previously returned candle.
PrecisionOHLC 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

ResultWhat your backend should do
OHLC_UNSUPPORTED_INTERVAL or OHLC_INVALID_RANGEFix the allowlisted input. Do not retry the same request.
OHLC_RANGE_TOO_LARGE or OHLC_BUCKET_LIMIT_EXCEEDEDShorten the requested window before trying again.
OHLC_BEFORE_AVAILABLE_HISTORYUse the returned availableFrom as the earliest boundary.
OHLC_UNAVAILABLE or 404Show a non-zero, explicit unavailable state for that asset.
401 or 403Fix server-side credentials or account access. Never ask the browser for the API Key.
429Stop automatic polling until quota becomes available. Do not retry in a loop.
502, 503, or 504Use 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.