eToro API quickstart: user key to first call
eToro API quickstart: create a demo user key in Settings, send x-request-id, x-api-key and x-user-key, and call GET /api/v1/me. Sourced on eToro's own API portal, with the one step it does not document marked unverified.
Risk warning: CFDs and spread bets are complex instruments and come with a high risk of losing money rapidly due to leverage. You can lose money on these products, and each provider publishes its own loss figure under FCA rules. You should consider whether you understand how they work and whether you can afford to take the high risk of losing your money. Automated and API trading can amplify losses through bugs, connectivity failures and runaway orders.
Demo accounts only. Credentials are read from environment variables on your own machine. No field on this site asks for a key, a username or a password.
Steps
You need a verified eToro account first
User keys are created inside the platform, at Settings, then Trading, then API Key Management, then Create New Key. Generating one is confirmed by a verification code sent to your mobile, so an unverified account cannot get to the end of the flow.
api-portal.etoro.comchecked
Make a Demo key with Read permission
Each key belongs to one environment, Demo or Real, and carries one permission level: Read for portfolio data, Write for trade execution. You can add an IP whitelist and an expiry date. Start with Demo and Read. A key made for Real will not work against Demo, and the reverse is also true.
api-portal.etoro.comchecked
The public API key is a second, separate key
Calls need two keys: x-api-key, the public API key that identifies the application, and x-user-key, the key you just made, which identifies your account. What is missing: none of the eToro API portal pages we opened on 19 Sep 2026, nor the authentication page as read again on 9 Oct 2026, says how the public API key is issued or who to ask for one. We have not confirmed a route to it, so we do not describe one.
Unverifiedapi-portal.etoro.com
Three headers on every call
Every request carries x-request-id, a fresh UUID for that call, plus x-api-key and x-user-key. The base URL is https://public-api.etoro.com/api/v1/. Endpoint pages offer an OAuth 2.0 bearer token as an alternative to the key pair rather than in addition to it, so send one or the other.
api-portal.etoro.comchecked
Make the first call: your own profile
GET /api/v1/me returns the authenticated user profile and is the shortest proof that both keys and the request id are right. eToro's own example call in the authentication page uses GET /api/v1/watchlists in the same shape if you would rather start there.
Python
# Set ETORO_API_KEY and ETORO_USER_KEY as environment variables first. # Never paste real values into code or into this site. import os import uuid import requests BASE_URL = "https://public-api.etoro.com/api/v1" headers = { "x-request-id": str(uuid.uuid4()), "x-api-key": os.environ["ETORO_API_KEY"], "x-user-key": os.environ["ETORO_USER_KEY"], } resp = requests.get(BASE_URL + "/me", headers=headers, timeout=10) resp.raise_for_status() print(resp.json()) # Send a fresh x-request-id on every call, not the same one twice.curl
# Set ETORO_API_KEY and ETORO_USER_KEY as environment variables first. # Never paste real values into code or into this site. curl -i "https://public-api.etoro.com/api/v1/me" \ -H "x-request-id: $(uuidgen)" \ -H "x-api-key: $ETORO_API_KEY" \ -H "x-user-key: $ETORO_USER_KEY"
api-portal.etoro.comchecked
Demo and real share one host, so read the path carefully
There is no separate demo hostname. Demo calls add a demo segment to the path, for example POST /api/v2/trading/execution/demo/orders against POST /api/v2/trading/execution/orders. The position of that segment moves between endpoints, and some real paths carry a real segment while others carry none. Copy the path from the endpoint page rather than assembling it yourself. Checked 18 Sep 2026.
api-portal.etoro.comchecked
Read the limits before you write a loop
The rate limits page gives 60 requests a minute for reads and 20 a minute for writes and execution, per user key over a rolling minute, with 429 when you go over and exponential backoff advised. Market data endpoints share a separate quota of 120 requests per 60 seconds across 11 endpoints. Checked 18 Sep 2026.
api-portal.etoro.comchecked
From first call to first order
One Python file built from the eToro endpoint records on this site, as the last step of this quickstart. It reads ETORO_API_KEY and ETORO_USER_KEY from the environment. GET /api/v1/trading/info/demo/portfolio (version v1) reads your open positions. POST /api/v2/trading/execution/demo/orders (version v2) places one small demo order with every field the record marks required (action and transaction), plus symbol, amount and orderType for the reason the record gives. GET /api/v2/trading/info/demo/orders:lookup (version v2) reads the confirmation or status. It sends at most one request every second, which stays under each request limit in the eToro limits table below. It refuses to run against any host but https://public-api.etoro.com and refuses any path that does not carry /demo/, and it never prints a token. Dry run is the default: save it as etoro_first_order.py, run python etoro_first_order.py, and it prints each request it would send with every secret shown as the name of its variable, reading no credential at all. Run python etoro_first_order.py send to send them to the demo host. The script has not been run against eToro.
Python
#!/usr/bin/env python3 """eToro demo API: from first call to first order. Unofficial example from Broker API Hub, not published by eToro. Written from the eToro endpoint records on openbrokerapi.com and not run against eToro: the quickstart page says so. Demo host only. What it does, in order: 1. proves the token with GET /api/v1/me (version v1) 2. GET /api/v1/trading/info/demo/portfolio (version v1): reads your open positions 3. POST /api/v2/trading/execution/demo/orders (version v2): places one small demo order with every field the record marks required 4. GET /api/v2/trading/info/demo/orders:lookup (version v2): reads the confirmation or status of that order Dry run is the default: nothing is sent, no credential is read, and each request is printed as it would go, with every secret shown as the name of the variable that holds it. Run it with the word send as the first argument (python etoro_first_order.py send) to send the requests to the demo host. Before a real send, set these environment variables on your own machine: ETORO_API_KEY the public API key that identifies the application ETORO_USER_KEY the Demo user key from Settings, then Trading, then API Key Management Never paste real values into this file or into any website. Needs Python 3.8 or later and requests (pip install requests). """ import json import os import sys import time import uuid import requests DEMO_HOST = "https://public-api.etoro.com" BASE_URL = os.environ.get("ETORO_BASE_URL", DEMO_HOST).rstrip("/") DEMO_PATH = "/demo/" # eToro has one host: a demo order carries this in its path, and this script refuses to send an order without it SEND = len(sys.argv) > 1 and sys.argv[1] == "send" # Pacing, from this site's limits register for eToro: # 60/min reads per key (user; checked 20 Sep 2026) # One request every second stays under each of them. MIN_GAP = 1 if BASE_URL != DEMO_HOST: sys.exit("Refusing to run: this script only talks to the demo host " + DEMO_HOST) # The order. Every field the order record marks required is here, with the record's own words after it, # and so is every field the vendor's page marks neither required nor optional (tagged not marked by the vendor). # A value of "..." or 0 is a placeholder: set it before a real send, or the script refuses to send. ORDER = { "action": "open", # open or close; eToro says only open is currently supported here. (required) "transaction": "buy", # buy, sell, sellShort or buyToCover; eToro says buy and sellShort are currently supported. (required) "symbol": "...", # Ticker symbol, as the alternative to instrumentId. (optional, sent because the record says to give exactly one of instrumentId or symbol) "orderType": "mkt", # mkt (market), mit (market if touched, needs triggerRate) or limitIOC (needs limitRate). (optional, sent because the record lists mkt, the market order type, first) "amount": 0, # Amount to invest in the order currency. Mutually exclusive with units and contracts. (optional, sent because the record says amount, units and contracts are mutually exclusive, so one of them is needed) } def env(name): """A credential or setting. In a dry run the value is the variable's own name, so nothing secret is read or printed.""" if not SEND: return "<" + name + ">" value = os.environ.get(name) if not value: sys.exit("Set the " + name + " environment variable first.") return value # Session and credential headers live in this object's memory only: never printed, never saved. http = requests.Session() http.headers.update({ "x-api-key": env("ETORO_API_KEY"), "x-user-key": env("ETORO_USER_KEY"), "Accept": "application/json", }) last_call = 0.0 def request(method, path, version=None, body=None, params=None): """One request: printed in a dry run, paced and sent otherwise. Exits on an error reply, without printing a token.""" global last_call headers = {} if version: headers["VERSION"] = version headers["x-request-id"] = str(uuid.uuid4()) # a fresh id per request, as the record asks if body is not None and DEMO_PATH not in path: sys.exit("Refusing to send " + path + ": a demo order path carries " + DEMO_PATH) if not SEND: print("DRY RUN " + method + " " + BASE_URL + path) shown = dict(http.headers) shown.update(headers) print(" headers: " + json.dumps(shown, indent=4)) if params: print(" query: " + json.dumps(params)) if body is not None: print(" body: " + json.dumps(body, indent=4)) return None wait = MIN_GAP - (time.monotonic() - last_call) if wait > 0: time.sleep(wait) last_call = time.monotonic() resp = http.request(method, BASE_URL + path, headers=headers, json=body, params=params, timeout=15) if resp.status_code >= 400: sys.exit("HTTP " + str(resp.status_code) + " from " + method + " " + path + ": " + resp.text[:300]) print(method + " " + path + ": HTTP " + str(resp.status_code)) return resp def main(): # GET /api/v1/me (version v1): Returns the profile behind your key or token: global and account IDs for real and demo, username, player level and the scopes granted. resp = request("GET", "/api/v1/me") demo_cid = resp.json()["demoCid"] if resp is not None else "<demoCid>" print("demoCid: " + str(demo_cid)) # GET /api/v1/trading/info/demo/portfolio (version v1): The demo account portfolio in detail: open positions, pending orders, copy trading details and balances, all under one clientPortfolio object. resp = request("GET", "/api/v1/trading/info/demo/portfolio") if resp is not None: print("Open positions: " + json.dumps(resp.json())[:600]) # POST /api/v2/trading/execution/demo/orders (version v2): Places an order on the demo account, by amount or units, with optional leverage, stop loss and take profit. The real account version is POST /api/v2/trading/execution/orders. if SEND and any(v in ("...", 0) for v in walk(ORDER)): sys.exit("Set every placeholder in ORDER before a real send.") resp = request("POST", "/api/v2/trading/execution/demo/orders", body=ORDER) ref = resp.json()["orderId"] if resp is not None else "<orderId from the order reply>" print("orderId: " + str(ref)) # GET /api/v2/trading/info/demo/orders:lookup (version v2): Returns the status of one demo order and every position it opened or closed. Look it up by orderId or by the referenceId you sent as x-request-id. resp = request("GET", "/api/v2/trading/info/demo/orders:lookup", params={"orderId": ref}) if resp is not None: print("Confirmation: " + json.dumps(resp.json(), indent=2)) else: print("Dry run finished. Nothing was sent. Run again with the word send as the first argument to send these requests to the demo host.") def walk(value): """Every leaf value in a nested dict or list, for the placeholder check.""" if isinstance(value, dict): for v in value.values(): yield from walk(v) elif isinstance(value, list): for v in value: yield from walk(v) else: yield value if __name__ == "__main__": try: main() except requests.RequestException as err: sys.exit("Network error: " + str(err))api-portal.etoro.comchecked
eToro limits to pace against
| Limit | Value | Scope | Source |
|---|---|---|---|
| Headline limit | 60/min reads per key | user | api-portal.etoro.comchecked |