Skip to content

Guides

Datafeed API

Bring your own data. You implement an object with a handful of methods; the chart calls them when it needs symbols and bars. We ship the chart and the logic, the market data is yours, and it never passes through our servers.

Bring your own data

This is the model, not an advanced option. We distribute a charting engine, not market data: you connect a feed you already have the right to show your readers, and the chart renders it. Nothing about your symbols or your bars reaches us.

That is a deliberate trade, and it is worth being plain about both halves:

  • Your data, your licence. Whatever you are permitted to display, you can display here. We are not in the middle re-publishing somebody’s feed to your readers.
  • Your quota, your scale. Requests go to your backend and grow with your traffic. There is no shared allowance to exhaust and no rate limit of ours between your readers and your chart.
  • You write about twenty lines. Two required methods. The quickstart below is a complete working feed you can paste as-is.
It is also enforced, not just recommended. An embedded chart on a site that is not ours and that names no datafeed does not fall back to our bars, it renders an explanation and a link to this page. We would rather a publisher meet that once, on their staging site, than have a chart quietly redistributing somebody’s licensed feed to their readers under our name.

Quickstart: a live Binance chart

Copy this into a page and open it. It draws BTCUSDT from Binance’s public market-data host, no key, no backend of your own, and no account with us.

Complete, working, copy as-is
<div class="vp-chart"
     data-symbol="BTCUSDT"
     data-datafeed="binance"
     data-height="480"></div>
<script src="https://view.verticalprop.com/embed.js"></script>

<script>
  // Chart resolutions → Binance intervals.
  var STEP = {
    '1': '1m', '5': '5m', '15': '15m', '30': '30m',
    '60': '1h', '240': '4h', '1D': '1d', '1W': '1w', '1M': '1M'
  };
  var sockets = {};

  vpEmbed.datafeed('binance', {
    onReady: function () {
      return { supported_resolutions: Object.keys(STEP) };
    },

    resolveSymbol: function (name) {
      return {
        name: name,
        description: name,
        session: '24x7',            // crypto never closes
        timezone: 'Etc/UTC',
        pricescale: 100,            // BTCUSDT quotes to 2 decimal places
        minmov: 1,
        has_intraday: true,
        supported_resolutions: Object.keys(STEP),
        data_status: 'streaming',
        provider: 'Binance'         // shown as attribution on the chart
      };
    },

    getBars: function (symbol, resolution, period) {
      var url = new URL('https://data-api.binance.vision/api/v3/klines');
      url.searchParams.set('symbol', symbol);
      url.searchParams.set('interval', STEP[resolution] || '1d');
      // endTime, NOT startTime: the chart needs `countBack` bars ENDING at
      // `to`, and asking from `from` returns too few whenever the window
      // holds less history than the chart asked for.
      url.searchParams.set('endTime', String(period.to * 1000));
      url.searchParams.set('limit', String(Math.min(1000, period.countBack)));

      return fetch(url).then(function (r) { return r.json(); }).then(function (rows) {
        // Binance already stamps kline open times in MILLISECONDS, which is
        // what a bar wants — even though period.from/to arrived in seconds.
        return rows.map(function (k) {
          return {
            time: k[0],
            open: Number(k[1]), high: Number(k[2]),
            low: Number(k[3]), close: Number(k[4]), volume: Number(k[5])
          };
        });
      });
      // Past the listing date Binance returns [], which is exactly how the
      // chart learns to stop asking for older history.
    },

    subscribeBars: function (symbol, resolution, uid, push) {
      var stream = symbol.toLowerCase() + '@kline_' + (STEP[resolution] || '1d');
      var ws = new WebSocket('wss://data-stream.binance.vision/ws/' + stream);
      ws.onmessage = function (ev) {
        var k = JSON.parse(ev.data).k;
        push({
          time: k.t,
          open: Number(k.o), high: Number(k.h),
          low: Number(k.l), close: Number(k.c), volume: Number(k.v)
        });
      };
      sockets[uid] = ws;
    },

    unsubscribeBars: function (uid) {
      if (sockets[uid]) { sockets[uid].close(); delete sockets[uid]; }
    }
  });
</script>
This exact code is run against a real chart in our browser test suite on every change, so it cannot rot into an example that no longer works. The three comments inside it name the three ways this integration usually goes wrong.
Cannot run a third-party script? You do not have to. embed.js is a convenience wrapper over a documented postMessage protocol, the same datafeed object, reached by a bridge you write and own in about thirty lines, with nothing of ours executing on your page. If a security review will not approve a remote <script>, start at the Wire protocol instead.

And this is the chart it draws, running here, on this page, right now. It is the same /embed document your snippet would frame, so what you are looking at is the product rather than a picture of it.

What a connected feed looks like. Change the interval or the instrument, every bar is a getBars call.

To take the contract apart rather than copy it, this playground is the same six methods over a series generated inside the pen. It reaches no venue, so it draws on the first run and any change you make is the only thing that can alter what appears.

A datafeed you can edit
<script src="https://view.verticalprop.com/embed.js"></script>
<div class="vp-chart"
     data-symbol="DEMO"
     data-interval="60"
     data-toolbar="1"
     data-theme="dark"
     data-datafeed="myFeed"
     style="height: 420px"></div>
<pre id="log"></pre>

<style>
body { margin: 0; background: #0b0f19; color: #e6e8eb;
       font: 13px/1.5 ui-monospace, monospace; }
#log { padding: 8px 12px; margin: 0; white-space: pre-wrap; }
</style>

<script>
// A deterministic series, so the pen never depends on a venue and the
// same code always draws the same chart.
function bars(from, to, stepSec) {
  const out = [];
  let price = 100;
  for (let t = Math.ceil(from / stepSec) * stepSec; t <= to; t += stepSec) {
    price += Math.sin(t / 5000) * 0.6;
    const open = price;
    const close = price + Math.cos(t / 3000) * 0.8;
    out.push({
      time: t * 1000,                       // a bar's time is MILLISECONDS
      open, close,
      high: Math.max(open, close) + 0.5,
      low: Math.min(open, close) - 0.5,
      volume: 1000 + (t % 500),
    });
  }
  return out;
}

vpEmbed.datafeed('myFeed', {
  onReady: () => ({ supported_resolutions: ['60'] }),
  resolveSymbol: (name) => ({
    name, ticker: name, pricescale: 100, session: '24x7', timezone: 'Etc/UTC',
  }),
  // from and to are SECONDS; a bar's own time is MILLISECONDS.
  getBars: (symbol, resolution, from, to) => bars(from, to, 3600),
  subscribeBars: () => {},
  unsubscribeBars: () => {},
});

const log = (m) => { document.getElementById('log').textContent += m + '\n'; };
const chart = vpEmbed.chart(document.querySelector('.vp-chart'));
chart.on('dataLoaded', () => log('bars drawn, every one from getBars'));
chart.on('intervalChanged', ({ interval }) => log('getBars called again for ' + interval));
</script>

Change the step in bars() or the supported_resolutions in onReady and watch what the chart asks for next.

Everything below is the reference for what those methods receive and must return.

The two pieces

One attribute and one registration. data-datafeed names an object on your page; naming one IS the request to supply data, so there is no second mode flag to forget.

Registration order does not matter. A feed registered after the chart mounted attaches to it; a chart mounted after the feed was registered picks it up. Both are ordinary on a page that loads scripts asynchronously.

Names are per placeholder, not per page. Two charts backed by two different feeds is the normal case for a comparison page, and a single global would answer both from whichever was registered last.

Required methods

Two. Everything else is optional and has a defined behaviour when absent, a feed that implements only these two draws a working chart.

resolveSymbol

Called once per symbol, before any bars are requested. Describe the instrument: how to format its prices, when it trades, and what to call it.

resolveSymbol(symbolName)  →  SymbolInfo | Promise<SymbolInfo>
FieldTypeDefaultMeaning
namestringrequiredWhat the chart shows as the instrument. The only required field.
descriptionstring–The longer name, shown beside it.
typestring–stock · crypto · forex · index · futures. Drives nothing but the label.
exchangestring–The venue. Shown in the legend, and compared against provider; see below.
timezoneIANA nameEtc/UTCDefaults to Etc/UTC. What the time axis is drawn in.
sessionstring24x7Trading hours: 24x7, or 0930-1600. Defaults to 24x7, which draws no gaps.
pricescalenumber10010^decimals, so 100 for two places. Zero or negative is REFUSED rather than defaulted: it decides where the decimal point goes on every price the reader sees, and a wrong price is worse than none.
minmovnumber1The smallest price step, usually 1. Positive or refused, same reasoning.
has_intradaybooleantrueWhether you serve resolutions below a day. False hides the intraday intervals.
supported_resolutionsstring[]allWhich intervals you can answer for. Anything you leave out is not offered.
volume_precisionnumber0Decimal places for volume. 0 for whole shares, more for a crypto pair.
data_status"streaming" | "endofday" | "delayed_streaming"streamingWhat the reader is told about freshness. ABSENT grades real-time, because every first-party feed omits it and defaulting to delayed would badge live charts with a louder lie.
providerstring–Who SERVED the bars, when that is not the exchange. Set it only when they differ: a value repeating exchange is dropped rather than printed twice. It earns a "≈" glyph: live data that reached the reader through somebody other than the venue can disagree with the official print.
delayseconds–Publisher lag, when you know it. Absence renders as "the lag varies by exchange", never as zero, because claiming real-time on somebody else’s behalf is the one thing this field must never do.

pricescale is the field to get right and the easiest to overlook: it decides where the decimal point goes on every price the reader sees, on the axis, in the legend and in the crosshair. A crypto pair quoted to eight places with the default 100 renders every price as the same rounded number.

Throw, or reject, to refuse a symbol. The chart shows the reader that the symbol could not be resolved rather than an empty plot.

resolveSymbol(name) {
  const meta = MY_SYMBOLS[name];
  if (!meta) throw new Error('unknown symbol');
  return {
    name,
    description: meta.longName,
    session: '0930-1600',
    timezone: 'America/New_York',
    pricescale: 100,
    minmov: 1,
    has_intraday: true,
    supported_resolutions: ['1', '5', '60', '1D'],
    data_status: 'delayed_streaming',
    delay: 900,
  };
}

pricescale is the field whose effect is easiest to see and easiest to get wrong. Two charts, one instrument, one number different:

pricescale 100, two decimals suit a $64,000 instrument
a five-decimal instrument needs 100000

getBars

Called whenever the chart needs history it does not have, on first load, and again each time the reader scrolls or zooms past the left edge of what it holds.

getBars(symbol, resolution, period)  →  Bar[] | Promise<Bar[]>
ParameterTypeMeaning
symbolstringThe name your resolveSymbol returned.
resolutionstringMinutes as a number (1, 5, 60, 240) or D, W, M.
period.fromnumberStart of the window, Unix SECONDS. Inclusive.
period.tonumberEnd of the window, Unix SECONDS. Exclusive.
period.countBacknumberHow many bars the chart needs. Prefer this over the window, see below.
period.firstDataRequestbooleanTrue on the first call for this symbol and resolution.

A bar is { time, open, high, low, close } with an optional volume. time is epoch MILLISECONDS while from and to are seconds. That asymmetry is inherited from the charting convention this API follows, it is the single most common mistake when connecting a feed, and it fails in a way that looks like no data at all: bars stamped in seconds land in 1970 and the chart draws nothing in the window it asked about.

[
  { time: 1755561600000, open: 100.0, high: 101.2, low: 99.4, close: 100.8, volume: 12500 },
  { time: 1755648000000, open: 100.8, high: 102.6, low: 100.1, close: 102.2, volume: 18300 }
]
FieldTypeDefaultMeaning
timenumberrequiredMilliseconds since the epoch, while from and to in the request are seconds. Inherited from the contract this mirrors, and the commonest connection mistake: it fails as a chart that draws nothing in the window it just asked about.
opennumberrequiredMust be finite. One NaN scales the whole visible range and empties the chart.
highnumberrequiredMust be finite.
lownumberrequiredMust be finite.
closenumberrequiredMust be finite.
volumenumber–Omit it rather than sending 0. A volume pane of zeroes is a claim that nothing traded.
levelsFootprintLevel[]–Order flow per price level, { price, bid, ask } ascending by price, read by the footprint chart type only. ABSENT is not EMPTY: absent means you supplied no order flow for this bar and the chart draws an empty column, never a distribution invented from the candle; [] means you did and nothing traded.
levelStepnumber–The price step the levels sit on. Positive. Absent, the chart uses the symbol’s tick, then the spacing of the levels themselves.
footprintSourcestring–exchange_trades, sub_candles or synthetic. Anything below exchange_trades is drawn with a quality ring. Absent: exchange_trades when levels is present.

Rules for the array you return

  • Ascending order, no duplicate timestamps. Checked, and refused with a message rather than rendered, a descending page draws a chart whose axis runs backwards inside one request and forwards across requests, which is very hard to recognise as an ordering problem.
  • Prefer countBack over the window. Return at least countBack bars ending at to, reaching further back than from if that is what it takes. The chart computes from from your session, so on a thinly traded symbol, or one with an unusual calendar, the window can hold fewer bars than it needs, and it will keep asking.
  • Return everything you have in the range. Truncating to exactly countBack when more exists leaves gaps the chart will ask about again.
  • Return an empty array when history runs out. That is how the chart learns to stop asking. A feed that always returns something turns one scroll to the left into an unbounded loop of requests.
  • Never return one bar. Two is the minimum a renderer can scale a range from.

Optional methods

Everything from here down can be left out. Each has a defined behaviour when absent, and none of them is called unless you implement it, so a feed that stops at getBars is a complete feed.

subscribeBars / unsubscribeBars

Real-time updates. The chart hands you a subscriber id and a function; call the function with a bar whenever you have one. A bar whose time matches the most recent one REPLACES it, that is how a forming candle updates, and a later time appends a new one.

subscribeBars(symbol, resolution, subscriberUID, push)
unsubscribeBars(subscriberUID)
const sockets = {};

subscribeBars(symbol, resolution, uid, push) {
  const ws = new WebSocket(`wss://my-feed.example/${symbol}`);
  ws.onmessage = (ev) => {
    const t = JSON.parse(ev.data);
    push({ time: t.time, open: t.o, high: t.h, low: t.l, close: t.c, volume: t.v });
  };
  sockets[uid] = ws;
},

unsubscribeBars(uid) {
  sockets[uid]?.close();
  delete sockets[uid];
}
You cannot rewrite history through push, a bar older than the newest one on the chart is ignored. If your data is corrected after the fact, the honest move is to say so in your own UI rather than to silently redraw the past.

Omit both for an end-of-day feed. The chart draws its history and stops, which is correct; it does not poll and does not show a stale “live” indicator.

A live subscription is what turns the last candle into a moving one. Watch the right-hand edge of this chart, every update is a push() from a feed:

A one-minute chart. The forming bar redraws on each tick the feed pushes.

searchSymbols

Powers the symbol search in the toolbar. Omit it if your embed charts one instrument, or if the reader should not be choosing.

searchSymbols(userInput, exchange, symbolType)  →  SearchItem[]

// SearchItem: { symbol, description?, exchange?, type?, ticker? }
// "ticker" is what resolveSymbol will be called with; defaults to "symbol".
FieldTypeDefaultMeaning
symbolstringrequiredWhat the row shows. A row without it is dropped.
descriptionstring–The longer name, shown beneath.
exchangestring–The venue badge on the row.
typestring–Feeds the asset-class filter chips.
tickerstringsymbolWhat resolveSymbol will be called with, when that differs from what the row displays. Defaults to symbol.

Rows that are not usable are dropped individually rather than failing the search, nine good results are worth more than an error about the tenth.

onReady

Called once, first. Return configuration for the chart’s own UI, which resolutions to offer, which exchanges and symbol types to show in search. Every field is optional and the whole method is optional.

onReady() {
  return {
    supported_resolutions: ['1', '5', '15', '60', '1D', '1W'],
    exchanges: [{ value: '', name: 'All', desc: '' }],
    symbols_types: [{ name: 'All', value: '' }],
  };
}

Call sequence

What happens when a reader opens a chart, in order:

First load. Nothing is requested until the symbol resolves, so a slow resolveSymbol delays everything after it.
Scrolling left past the loaded history. resolveSymbol is not called again; only getBars, with firstDataRequest false.
Changing symbol. The old subscription is dropped before the new one opens, so a feed that leaks sockets leaks one per symbol change.

Errors and timeouts

Throw or reject to report a failure. The message reaches the chart and is shown to the reader, so write it for them rather than for your logs.

A call that is never answered times out after 20 seconds and is reported as your feed not responding. That bound exists because the alternative, a chart waiting forever, is indistinguishable from a slow network, and the reader can do nothing about either.

Malformed data is refused with a specific reason rather than rendered: a bar that is not five finite numbers, a pricescale of zero, an unordered page. The reason names the field and, for bars, the index, these are checked because one bad value scales the whole visible range and takes the entire chart with it.

What we see

Nothing. In this mode the chart makes no market-data request, so no symbol you chart and no bar you draw reaches our servers. The frame still loads its own code and styles from us that is what an embed is, and the community-indicator features, if you enable them, still talk to us because that is what they are for.

getServerTime

Your server’s clock, in seconds. The chart draws its live edge against “now”; without this it uses the reader’s own clock, so a skewed one puts that edge in the wrong place.

getServerTime() {
  return Math.floor(Date.now() / 1000);   // or your server's, which is the point
}

getMarks

The round badge under a bar — a B or an S where a fill happened, with a tooltip on hover. Asked for only when your onReady config sets supports_marks: true.

onReady() {
  return { supported_resolutions: ['1D'], supports_marks: true };
},

getMarks(symbolInfo, from, to, resolution) {
  return fills.filter((f) => f.time >= from && f.time <= to).map((f) => ({
    id: f.id,
    time: f.time,               // SECONDS, and the BAR's own opening time
    label: f.side === 'buy' ? 'B' : 'S',
    color: f.side === 'buy' ? '#26a69a' : '#ef5350',
    labelFontColor: '#ffffff',
    text: [`${f.side} ${f.qty} @ ${f.price}`],
  }));
}
A mark is looked up by the BAR it belongs to, so time must be a bar's own opening time and not merely a moment inside it. One that falls between bars draws nothing and reports nothing. Note that a daily bar's anchor is the venue's, which is not always midnight UTC.

To push a set rather than answer a callback, which is the usual shape when the chart uses our market data and only the fills are yours, use chart.setMarks(marks) from your page. When both are present the pushed set wins.

getTimescaleMarks

Roundels in the time gutter, for events anchored to a moment rather than a level. Asked for only when your config sets supports_timescale_marks: true.

getTimescaleMarks(symbolInfo, from, to, resolution) {
  return earnings.map((e) => ({
    id: e.id,
    time: e.time,               // SECONDS
    label: 'E',
    color: '#7c5cff',
    tooltip: [e.title, e.detail],
  }));
}
If the embed also carries data-calendar="1", both sets are drawn: a macro release and your own event are compatible statements about one timeline, and the chart already columns and stacks co-timed roundels.

searchSymbolsPaginated

A page of search results, for a catalogue too big to answer in one go.

searchSymbolsPaginated(query, exchange, type, page) {
  const size = 50;
  const all = myUniverse.filter((s) => s.symbol.includes(query.toUpperCase()));
  return {
    items: all.slice((page - 1) * size, page * size),
    hasMore: all.length > page * size,
  };
}
hasMore is the point. Without it the chart cannot tell a short page from the end of the catalogue, so the reader scrolls, the list stops, and nothing says there is more. Returning a bare array is read as one page with nothing further, which is exactly what the unpaged searchSymbols means.