Guides
Wire protocol
Run an embedded chart with no script of ours on your page. One iframe, a postMessage handler you write and own, and about thirty lines of bridge. This is what embed.js does for you, written out, so you can do it yourself.
When you want this
Most people should use embed.js. It is one tag, it handles this protocol for you, and the Datafeed API page is where to start.
This page is for the case where a remote script is not acceptable, an exchange, a broker, a bank, anyone whose page also renders balances or positions and whose security review will not approve third-party code executing on that origin. That objection is correct, and you do not have to concede it to use the chart.
embed.js is a convenience wrapper over exactly the messages below, the same datafeed shape, the same chart, the same resize handshake. What you give up is us maintaining thirty lines on your behalf.What executes where
This is the section a security review actually wants, so it is stated as a boundary rather than as reassurance.
| Runs in YOUR origin | Runs in OURS |
|---|---|
| Your datafeed, your bridge, your bundle. Code you wrote and reviewed. | The chart engine, the /embed document, community indicators. |
| Nothing of ours. No script, no font, no stylesheet, no XHR, no image, no beacon. | Under our CSP and our storage, a separate origin, which is what an iframe is. |
The only thing that loads from us is the frame document, and a document in an iframe executes in its own origin, not yours. Your realm is untouched: nothing of ours can read your DOM, your cookies, your localStorage, or a variable on your page.
Your Content-Security-Policy
One line.
frame-src https://view.verticalprop.com;script-src, connect-src, worker-src and blob:. The loader form needs a script-src entry; this one does not, because there is no script of ours to allow. And community indicators, which are strangers’ code, never touch your policy at all: they run in a sandboxed frame nested inside ours, under a default-src 'none' policy we serve, on a separate domain we own. That containment is the reason this product is an iframe and not an npm package.For a reviewer, that is the difference between “we allow one frame from a named origin” and “we execute a third party’s JavaScript in our own origin, on a page that renders customer balances”. They are not the same conversation, and the second one is the one you should refuse.
The envelope
Every message in both directions carries a namespace and a protocol version. Anything without them is ignored, so your handler can share a page with any other postMessage traffic.
{ "ns": "vp-embed", "v": 1, "type": "…", … }The version is 1. It exists because a snippet you paste today outlives a server we redeploy a hundred times, a newer frame can recognise an older host and behave, rather than going silent. If it ever changes, the old value keeps working.
What the frame sends you
| type | Fields | Meaning |
|---|---|---|
ready | height | The chart is mounted and will accept commands. Sent once. |
resize | height | The chart's natural height changed. Apply it to the frame, or ignore it and keep a fixed height. |
error | reason | The chart could not start, a refused symbol, or a missing datafeed. Worth logging; the frame also shows it. |
datafeedCall | id, method, args | The chart is asking your feed for something. Answer with datafeedResult or datafeedError, quoting the same id. |
commandResult | id, value | A command you sent succeeded. value is whatever that method returns, often a handle. |
commandError | id, reason | A command you sent was refused, and why. An unknown property is named here rather than dropped. |
event | name, payload | Something happened on the chart you asked to hear about, an order line dragged, a marker clicked. |
What you send back
| type | Fields | Meaning |
|---|---|---|
datafeedResult | id, result | The answer to one datafeedCall. id must match the call. |
datafeedError | id, reason | That call cannot be answered. reason is shown in the browser console, not to the reader. |
datafeedTick | id, bar | A realtime bar. id is the subscriberUID you were given in subscribeBars, not a call id. |
datafeedTrade | id, trade | One executed print for a footprint chart, { price, size, side, timestamp }. id is the subscriberUID from subscribeTrades. |
setSymbol | symbol | Change the instrument. The host already chose one in the URL, so this changes nothing it could not already decide. |
setTheme | theme | light or dark, for a page with its own theme toggle. |
command | id, method, args | Drive the chart, order lines, markers, drawings, interval. One type for the whole control API; the methods are on the JavaScript API page. |
datafeedTick’s id is a subscription, not a call. Every other message pairs a reply to the id of the call that asked for it. Ticks are unsolicited, so they are addressed to the subscriberUID from subscribeBars instead. Sending a call id there means the chart has nothing listening and the bar is dropped silently.Driving the chart from here
The datafeed is one half of the wire. The other is the control API, order lines, position lines with a bracket, markers, drawings, indicators, interval and chart type, and it rides the same envelope: send command with a method and args, read commandResult or commandError back, and subscribe to event for what the reader does.
One message type covers the whole API rather than one per method, so this list stays readable. The method names, their arguments and every property they accept are on the JavaScript API page, the same vocabulary whether you reach it through vpEmbed.chart() or by posting the message yourself.
commandError naming it. That is deliberate: silently dropping it is a chart that looks like it worked and did not.The chart below is being driven exactly that way. There is no embed.js on this page, which you can confirm in your own network tab: the buttons build the envelope by hand and call postMessage, and the log shows both directions including the refusal.
Nothing appears until you ask for it, and it needs the toolbar: on a chrome-less embed the same call is refused, naming the attribute.
The datafeed methods
These are the conventional datafeed method names, deliberately, so if you have written one before this is the same object. Only resolveSymbol and getBars are required.
| method | args | What you return |
|---|---|---|
onReady | – | Return your feed's configuration, e.g. { supported_resolutions: ['1D'] }. |
searchSymbols | query, exchange, type | Return an array of matches. Optional, omit it and the chart's symbol search stays empty. |
resolveSymbol | symbol | Return the symbol description. name is the only required field. |
getBars | symbol, resolution, period | Return bars ascending by time. period is {from, to, countBack, firstDataRequest}. |
subscribeBars | symbol, resolution, subscriberUID | Start streaming. Send each new bar as a datafeedTick addressed to subscriberUID. |
unsubscribeBars | subscriberUID | Stop that stream and release whatever it holds. |
getMarks | symbol, from, to, resolution | Return the per-bar badges in that window. Optional, and asked for only when your onReady config sets supports_marks: true. from/to and each mark's time are SECONDS. |
getServerTime | – | Return your server's time in SECONDS. Optional; without it the chart places the live edge on the reader's own clock. |
getTimescaleMarks | symbol, from, to, resolution | Return gutter roundels for events anchored to a moment rather than a level. Optional, and asked for only when your onReady config sets supports_timescale_marks: true. |
searchSymbolsPaginated | query, exchange, type, page | Return { items, hasMore }. Optional; without it the one-shot searchSymbols applies and your page size becomes the end of the catalogue. |
getFootprint | symbol, resolution, period | Return order flow for that window as { time, levels, levelStep?, footprintSource? } per bar, time in MILLISECONDS. Optional, and asked for only while the footprint chart type is showing. |
subscribeTrades | symbol, resolution, subscriberUID | Start streaming order flow. Send each print as a datafeedTrade addressed to subscriberUID. Opened only while the footprint chart is showing. |
unsubscribeTrades | subscriberUID | Stop that order-flow stream. |
bar.time is milliseconds; period.from and period.to are seconds. Inherited from the contract this mirrors, and the single commonest mistake here. It fails as a chart that draws nothing in the window it asked about, which looks exactly like having no data for that symbol.Pin the origin and the source
Check both. event.origin proves the message came from our document; event.source proves it came from THIS frame. Origin alone is not enough, a page embedding two of our charts would let either one answer for the other, and the bug only appears on the page that has two.
The frame cannot check your origin, because anybody may embed us, so it validates shape instead and rejects unknown fields. It does check the source, so post from the page that holds the iframe: a message from any other frame or a popup is ignored. That asymmetry is deliberate and is described on the Security model page.
A complete host
Copy-pasteable as-is against Binance’s public market-data host, which is keyless and CORS-open so it runs from your page on the first try. The datafeed object is identical to the one on the Datafeed API page, only the way it is reached differs.
<!-- Nothing of ours executes on your page. One frame, and a handler you own. -->
<iframe id="vp-chart"
src="https://view.verticalprop.com/embed?symbol=BTCUSDT&datafeed=host"
width="100%" height="480" frameborder="0"
title="VerticalProp chart"></iframe>
<script>
var EMBED_ORIGIN = 'https://view.verticalprop.com';
var frame = document.getElementById('vp-chart');
/* ---- 1. Your datafeed -------------------------------------------------
The same object you would hand any charting library. Nothing about it
knows it is behind an iframe. */
var STEP = {
'1': '1m', '5': '5m', '15': '15m', '30': '30m',
'60': '1h', '240': '4h', '1D': '1d', '1W': '1w', '1M': '1M'
};
var sockets = {};
var feed = {
onReady: function () {
return { supported_resolutions: Object.keys(STEP) };
},
resolveSymbol: function (name) {
return {
name: name,
description: name,
session: '24x7',
timezone: 'Etc/UTC',
pricescale: 100,
minmov: 1,
has_intraday: true,
supported_resolutions: Object.keys(STEP),
data_status: 'streaming',
provider: 'Binance'
};
},
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');
// period.from/to are SECONDS; bar.time is MILLISECONDS. The commonest
// mistake in this integration, and it fails as an empty chart.
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) {
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])
};
});
});
},
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]; }
}
};
/* ---- 2. The bridge ----------------------------------------------------
This is all of it. */
function send(type, payload) {
payload.ns = 'vp-embed';
payload.v = 1;
payload.type = type;
// Targeted, not '*': there is exactly one window this belongs to.
frame.contentWindow.postMessage(payload, EMBED_ORIGIN);
}
window.addEventListener('message', function (ev) {
// PIN BOTH. The origin proves the message came from our document; the
// source proves it came from THIS frame. Origin alone is not enough — a
// page embedding two of our charts would let one drive the other.
if (ev.origin !== EMBED_ORIGIN) return;
if (ev.source !== frame.contentWindow) return;
var m = ev.data;
if (!m || m.ns !== 'vp-embed' || m.v !== 1) return;
// Optional: grow the frame to the chart. This is the one thing embed.js
// does that you would otherwise miss, and it is two lines.
if (m.type === 'ready' || m.type === 'resize') {
frame.style.height = m.height + 'px';
return;
}
if (m.type !== 'datafeedCall') return;
var fn = feed[m.method];
if (typeof fn !== 'function') {
send('datafeedError', { id: m.id, reason: 'no such method: ' + m.method });
return;
}
var args = m.args.slice();
if (m.method === 'subscribeBars') {
// A function cannot cross the boundary, so the push callback is made
// HERE and its ticks are addressed by the subscription id.
var uid = args[2];
args.push(function (bar) { send('datafeedTick', { id: uid, bar: bar }); });
}
// Sync or async, both work — the reply is always sent from a promise.
Promise.resolve().then(function () { return fn.apply(feed, args); })
.then(function (result) { send('datafeedResult', { id: m.id, result: result }); })
.catch(function (err) {
send('datafeedError', { id: m.id, reason: String((err && err.message) || err) });
});
});
</script>This exact text is injected into a real host page and driven against a real chart by our browser suite, which asserts that a chart drew AND that no embed.js request was made. A documented example nobody runs is a guess.
…and the version you would actually bundle
The page above is one file so you can try it in thirty seconds. In an application you want a module: import it, call it from wherever the frame mounts, and call the teardown it returns.
// vp-chart.js — yours. Bundle it, review it, change it.
// Nothing is fetched from us at runtime; the only thing that loads from our
// origin is the iframe document itself, which runs under OUR policy, not yours.
export const VP_ORIGIN = 'https://view.verticalprop.com';
/** Build the frame URL. `datafeed: 'host'` is what tells the chart to ask YOU
* for bars — without it you get ours, which a third-party page is refused. */
export function vpChartSrc(params, origin = VP_ORIGIN) {
const qs = new URLSearchParams({ datafeed: 'host', ...params });
return origin + '/embed?' + qs.toString();
}
/**
* Connect a datafeed object to a mounted VerticalProp chart frame.
*
* @param frame the <iframe> element
* @param feed your datafeed: { onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars }
* @param opts { origin, onResize, onError }
* @returns a teardown function — CALL IT on unmount
*/
export function attachVpChart(frame, feed, opts = {}) {
const origin = opts.origin || VP_ORIGIN;
const send = (type, payload) => {
// Targeted, never '*': there is exactly one window this belongs to.
frame.contentWindow?.postMessage({ ...payload, ns: 'vp-embed', v: 1, type }, origin);
};
const onMessage = (ev) => {
// PIN BOTH. Origin proves it came from our document; source proves it came
// from THIS frame. Origin alone lets one chart answer for another the day
// somebody puts two on a page.
if (ev.origin !== origin) return;
if (ev.source !== frame.contentWindow) return;
const m = ev.data;
if (!m || m.ns !== 'vp-embed' || m.v !== 1) return;
if (m.type === 'ready' || m.type === 'resize') { opts.onResize?.(m.height); return; }
if (m.type === 'error') { opts.onError?.(m.reason); return; }
if (m.type !== 'datafeedCall') return;
const fn = feed[m.method];
if (typeof fn !== 'function') {
// An optional method you did not implement is normal — searchSymbols is
// the usual one. Answering keeps the chart from waiting on it.
send('datafeedError', { id: m.id, reason: 'not implemented: ' + m.method });
return;
}
const args = m.args.slice();
if (m.method === 'subscribeBars') {
// A function cannot cross the boundary, so the push callback is made
// HERE. Its ticks are addressed to the SUBSCRIPTION id, not the call id.
const uid = args[2];
args.push((bar) => send('datafeedTick', { id: uid, bar }));
}
// Sync or async both work — the reply always goes out from a promise.
Promise.resolve()
.then(() => fn.apply(feed, args))
.then((result) => send('datafeedResult', { id: m.id, result }))
.catch((err) => send('datafeedError', { id: m.id, reason: String(err?.message ?? err) }));
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}window, so an attach without a matching remove leaks one per mount, and under React StrictMode that is two on the very first render, so every datafeedCall is answered twice. The chart tolerates it, which is what makes it expensive: it surfaces months later as duplicated subscriptions nobody can account for.React
The framework this lands in most often, and the one where the teardown matters most.
import { useEffect, useRef } from 'react';
import { attachVpChart, vpChartSrc } from './vp-chart';
const feed = {
onReady: () => ({ supported_resolutions: ['1', '5', '60', '1D'] }),
resolveSymbol: (name) => ({
name, description: name, session: '24x7', timezone: 'Etc/UTC',
pricescale: 100, minmov: 1, has_intraday: true,
}),
getBars: async (symbol, resolution, period) => {
const r = await fetch(`/api/bars?symbol=${symbol}&res=${resolution}&to=${period.to}`);
return r.json(); // [{ time, open, high, low, close, volume }] — time in MILLISECONDS
},
subscribeBars: (symbol, resolution, uid, push) => openStream(symbol, resolution, uid, push),
unsubscribeBars: (uid) => closeStream(uid),
};
export function VerticalChart({ symbol = 'BTCUSDT', height = 480 }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
// The teardown is not optional — without it StrictMode's double mount
// installs two listeners and every call is answered twice.
return attachVpChart(ref.current, feed);
}, []);
return (
<iframe
ref={ref}
src={vpChartSrc({ symbol, interval: '60' })}
style={{ width: '100%', height, border: 0 }}
loading="lazy"
title="VerticalProp chart"
/>
);
}Vue, Svelte and plain JavaScript are the same three lines in their own idiom, attach on mount, keep the returned function, call it on unmount. Loader-form recipes for each are on the Framework recipes page.
What you are still relying on us for
Worth being straight about, because a review will ask and “nothing” would be false.
- The chart engine and the frame document. Served from our origin, updated by us. Your bridge does not pin a version of it.
- This protocol staying stable. Your bridge lives in your repo on your release cycle, so a rename on our side would be a chart that stops drawing on your page. Every message carries a version for that reason, and our CI fails if this page or the example above drifts from the protocol the code implements, the docs cannot silently go stale.
- Availability of the frame. If our origin is down the chart does not render. Your data, your backend and the rest of your page are unaffected, nothing of ours is in the path.
What you are NOT relying on us for: bars, symbols, market-data licensing, rate limits and quota. Those never reach us, the chart asks YOUR page, your page answers, and our browser suite asserts that on the network rather than taking it on trust. We cannot see a symbol you resolve or a bar you serve.