Why do we need Google Apps Script?
To exchange trade data between separate MetaTrader terminals, a simple relay server is required. Google Apps Script acts as a free intermediary that transfers trade events from the Master account to the Slave account. It ensures reliable delivery of events even after internet interruptions or terminal restarts and does not require a VPS or dedicated server.
How to create and deploy Google Apps Script
-
Go to https://script.google.com and Click Start scripting

-
Click New project

-
Delete the default code and paste the script provided below the instruction steps

-
Press Ctrl + S to save the project (the top menu will become active)

-
Click Deploy in the top-right corner and select New deployment

-
In the opened window, click Select type (⚙️) and choose Web app

-
In the Description field, enter Version 1 (any text is fine). Set Who has access to Anyone and leave Execute as unchanged

-
Click Deploy - your Apps Script URL will be generated. Copy and paste this URL into the EA input settings

const API_KEY = 'I_AM_API_KEY'; // Number of events to prune in a single pass (to avoid execution time limits) const MAX_PRUNE = 200; // A consumer is considered "active" if it was seen within this time window (ms) const CONSUMER_TTL_MS = 6 * 60 * 60 * 1000; // 6 h // max consumers remembered per channel (to avoid growth) const MAX_CONSUMERS_PER_CHANNEL = 50; // ---------------- Web handlers ---------------- function doPost(e) { const lock = LockService.getScriptLock(); let locked = false; try { lock.waitLock(10000); locked = true; if (!e || !e.postData || !e.parameter) return _resp({ ok:false, error:'no data' }); const key = (e.parameter.key || '').toString(); if (key !== API_KEY) return _resp({ ok:false, error:'forbidden' }); const channel = (e.parameter.channel || 'default').toString(); const consumer = (e.parameter.consumer || '').toString(); const c = consumer || 'single'; const store = PropertiesService.getScriptProperties(); // tolerant JSON parse let raw = e.postData.contents || '{}'; raw = raw.replace(/[\u0000-\u001F]+$/g, ''); let body; try { body = JSON.parse(raw); } catch (parseErr) { return _resp({ ok:false, error:'bad json', details:String(parseErr) }); } // mark consumer as seen + register in channel consumer list (FAST) // + NEW RULE: if consumer is new => ack = seq (start from "now", no replay) _touchConsumerFast(store, channel, c); // ---- ACK from slave ---- if (body && body.action === 'ack') { const lastId = Number(body.last_id || 0); if (!lastId) return _resp({ ok:false, error:'bad ack' }); store.setProperty(_ackKey(channel, c), String(lastId)); // prune ONLY by minAck across all ACTIVE consumers _pruneByMinAckFast(store, channel); return _resp({ ok:true, ack:lastId }); } // ---- event from master ---- const nextId = _nextSeq(store, channel); body.id = nextId; body.server_time_ms = Date.now(); // store event as separate key store.setProperty(_evKey(channel, nextId), JSON.stringify(body)); // set min_id if empty const minKey = _minKey(channel); const curMin = Number(store.getProperty(minKey) || '0'); if (!curMin) store.setProperty(minKey, String(nextId)); return _resp({ ok:true, last_id: nextId }); } catch (err) { return _resp({ ok:false, error:'exception', message:String(err), stack:(err && err.stack) ? String(err.stack) : '' }); } finally { if (locked) { try { lock.releaseLock(); } catch(_) {} } } } function doGet(e) { const lock = LockService.getScriptLock(); let locked = false; try { lock.waitLock(10000); locked = true; if (!e || !e.parameter) return _resp({ ok:false, error:'no params' }); const key = (e.parameter.key || '').toString(); if (key !== API_KEY) return _resp({ ok:false, error:'forbidden' }); const channel = (e.parameter.channel || 'default').toString(); const consumer = (e.parameter.consumer || '').toString(); const c = consumer || 'single'; const limit = Math.max(1, Math.min(100, Number(e.parameter.limit || 20))); const store = PropertiesService.getScriptProperties(); // mark consumer as seen + register in channel consumer list (FAST) // + NEW RULE: if consumer is new => ack = seq (start from "now", no replay) _touchConsumerFast(store, channel, c); const minId = Number(store.getProperty(_minKey(channel)) || '0'); const seq = Number(store.getProperty(_seqKey(channel)) || '0'); const ackKey = _ackKey(channel, c); let ack = Number(store.getProperty(ackKey) || '0'); // If events were pruned and consumer is behind, auto-catch-up to min_id-1 (prevents gap_detected) if (minId > 0) { const floorAck = Math.max(0, minId - 1); if (ack < floorAck) { ack = floorAck; store.setProperty(ackKey, String(ack)); } } // optional health/debug mode const mode = (e.parameter.mode || '').toString(); if (mode === 'health' || mode === 'debug') { const consumers = _listActiveConsumersFast(store, channel); const minAck = _minAckFast(store, channel, consumers); const out = { ok:true, channel, consumer:c, ack, seq, min_id:minId, active_consumers:consumers, min_ack:minAck }; if (mode === 'debug') { // light debug: show consumer seen timestamps (active only) const seen = {}; for (const cc of consumers) { seen[cc] = Number(store.getProperty(_seenKey(channel, cc)) || '0'); } out.seen = seen; } return _resp(out); } const events = []; let missing_id = 0; // strictly sequential read: ack+1, ack+2... for (let id = ack + 1; id <= seq && events.length < limit; id++) { const evStr = store.getProperty(_evKey(channel, id)); if (!evStr) { missing_id = id; break; } try { events.push(JSON.parse(evStr)); } catch (parseErr) { missing_id = id; break; } } // If a "gap" is actually just "consumer behind pruned min_id", auto-resync and retry once if (missing_id && minId > 0 && missing_id < minId) { const newAck = Math.max(0, minId - 1); store.setProperty(ackKey, String(newAck)); const events2 = []; let missing2 = 0; for (let id = newAck + 1; id <= seq && events2.length < limit; id++) { const evStr = store.getProperty(_evKey(channel, id)); if (!evStr) { missing2 = id; break; } try { events2.push(JSON.parse(evStr)); } catch (_) { missing2 = id; break; } } if (!missing2) { return _resp({ ok:true, ack: newAck, seq: seq, events: events2 }); } // real gap (corruption), report return _resp({ ok:false, error:'gap_detected', ack: newAck, seq: seq, missing_id: missing2 }); } if (missing_id) { return _resp({ ok:false, error:'gap_detected', ack: ack, seq: seq, missing_id: missing_id }); } return _resp({ ok:true, ack: ack, seq: seq, events: events }); } catch (err) { return _resp({ ok:false, error:'exception', message:String(err), stack:(err && err.stack) ? String(err.stack) : '' }); } finally { if (locked) { try { lock.releaseLock(); } catch(_) {} } } } // ---------------- helpers ---------------- function _nextSeq(store, channel) { const k = _seqKey(channel); const next = Number(store.getProperty(k) || '0') + 1; store.setProperty(k, String(next)); return next; } // consumer registry (FAST): // - keep lastSeen per channel+consumer // - keep compact list of consumers per channel in one JSON key // - NEW RULE: if consumer has no ACK yet => ack = seq (start from now, no replay) function _touchConsumerFast(store, channel, consumer) { const now = Date.now(); store.setProperty(_seenKey(channel, consumer), String(now)); const listKey = _consumersKey(channel); let arr = []; try { arr = JSON.parse(store.getProperty(listKey) || '[]'); } catch(_) { arr = []; } if (arr.indexOf(consumer) < 0) { arr.push(consumer); // keep bounded if (arr.length > MAX_CONSUMERS_PER_CHANNEL) arr = arr.slice(arr.length - MAX_CONSUMERS_PER_CHANNEL); store.setProperty(listKey, JSON.stringify(arr)); } const ackKey = _ackKey(channel, consumer); const ackStr = store.getProperty(ackKey); // NEW RULE: if consumer is new (no ACK stored yet) => start from "now" // ack = current seq (so GET returns only future events) if (ackStr === null || ackStr === undefined || ackStr === '') { const seq = Number(store.getProperty(_seqKey(channel)) || '0'); store.setProperty(ackKey, String(Math.max(0, seq))); return; } // If consumer exists but is behind pruned min_id, catch it up automatically (prevents gap_detected) const minId = Number(store.getProperty(_minKey(channel)) || '0'); const ack = Number(ackStr || '0'); if (minId > 0) { const floorAck = Math.max(0, minId - 1); if (ack < floorAck) store.setProperty(ackKey, String(floorAck)); } } function _listActiveConsumersFast(store, channel) { const now = Date.now(); const listKey = _consumersKey(channel); let arr = []; try { arr = JSON.parse(store.getProperty(listKey) || '[]'); } catch(_) { arr = []; } const active = []; for (const c of arr) { const seen = Number(store.getProperty(_seenKey(channel, c)) || '0'); if (!seen) continue; if (now - seen <= CONSUMER_TTL_MS) active.push(c); } if (active.length === 0) active.push('single'); return active; } function _minAckFast(store, channel, consumers) { let min = null; for (const c of consumers) { const a = Number(store.getProperty(_ackKey(channel, c)) || '0'); if (min === null || a < min) min = a; } return min === null ? 0 : min; } function _pruneByMinAckFast(store, channel) { const consumers = _listActiveConsumersFast(store, channel); const minAck = _minAckFast(store, channel, consumers); if (minAck <= 0) return; _pruneAckedUpTo(store, channel, minAck); } function _pruneAckedUpTo(store, channel, ackId) { const minKey = _minKey(channel); let minId = Number(store.getProperty(minKey) || '0'); if (!minId) return; let removed = 0; while (minId && minId <= ackId && removed < MAX_PRUNE) { store.deleteProperty(_evKey(channel, minId)); minId++; removed++; } const seq = Number(store.getProperty(_seqKey(channel)) || '0'); if (minId > seq) { store.deleteProperty(minKey); // queue empty } else { store.setProperty(minKey, String(minId)); } } // keys function _seqKey(channel) { return channel + '__seq'; } function _minKey(channel) { return channel + '__min'; } function _ackKey(channel, consumer) { return channel + '__ack__' + consumer; } function _evKey(channel, id) { return channel + '__ev__' + id; } function _seenKey(channel, consumer) { return channel + '__seen__' + consumer; } function _consumersKey(channel) { return channel + '__consumers'; } function _resp(obj) { // IMPORTANT: do NOT use out.setHeader() (not supported) // and do NOT use ContentService.CacheControl.* (may be undefined). // Use client-side cache-buster: add &ts=... to GET on the slave. return ContentService .createTextOutput(JSON.stringify(obj)) .setMimeType(ContentService.MimeType.JSON); }


