How to setup 'Grid Copier' and 'Copy Trades' with Google Apps Script (step-by-step)
Why does the setup require Google Apps Script
Copy Trades is designed for remote trade copying between MetaTrader terminals running on different computers or VPS servers. Because of this, the initial setup requires a few additional steps compared to local trade copiers.
This functionality is rare in the MQL5 Market.
If you have any difficulties during the setup, feel free to send me a private message. I will be happy to help you configure the utility step by step.
Google Apps Script acts as a free relay service that transfers trade events from the Master account to the Slave account. Once configured, 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 (8 steps)
-
Go to https://script.google.com and Click Start scripting

-
Click New project

-
Delete the default code and paste the script below

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); } -
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

- In MetaTrader 5 → Tools → Options → Expert Advisor tab, enable WebRequest for your Google Apps Script URL and allow algorithmic trading.
- Attach Grid Copier or Copy Trades on the required symbol on the Master account (the source account) and set the role to Master in the input parameters.
- Attach Grid Copier or Copy Trades on the same symbol on the Slave account (the target account) and set the role to Slave in the input parameters.
- Enter the Google Apps Script URL in the GridCopier inputs on both Master and Slave.
- Enter the Api Key from Google Apps Script (for example, I_AM_API_KEY) in the inputs on both Master and Slave. The Api Key is a simple “password” that protects your copying channel from unauthorized access (you can use any text).
- Set the same Channel Name on both Master and Slave (for example: symbol_copier). This is the trade queue name and must match on both sides.
Each independent Master–Slave pair must use its own unique Channel Name. - Set the Trade ID (magic) for Server on the Slave, for example: slave_1.
- Copying will start automatically.
If you use Copy Trades and Grid Copier in one time - it is recommended to create two separate Apps Script URL.
If you have questions or assist please reach out to me.


