OANDA v20 API in Python: token to first call
Make your first OANDA v20 API call in Python, JavaScript or curl: personal token, practice host and account summary, using placeholder credentials only.
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
Generate a personal access token
Log in to the Account Management Portal on fxTrade, select Manage API Access under My Services, agree to the licence and generate your token. Not verified: whether one token works on both hosts. OANDA's pages as read on 12 Oct 2026 do not say. The development guide pairs the practice host with your fxTrade Practice account and the live host with your fxTrade account, and the authentication page says a token is unique to an OANDA account. This page said until 12 Oct 2026 that each environment needs its own token and that the wrong one fails with Insufficient authorization to perform request: that is not on the pages cited, so it is withdrawn as a statement of fact. Generate the token from the account you mean to call. The samples below all call the practice host, so generate the practice token.
developer.oanda.comchecked
Pick your host
Practice REST: https://api-fxpractice.oanda.com. Live REST: https://api-fxtrade.oanda.com. Streaming lives on stream-fxpractice.oanda.com and stream-fxtrade.oanda.com.
developer.oanda.comchecked
Send the token as a Bearer header
Every request carries Authorization: Bearer followed by your token, which the code reads from the OANDA_TOKEN environment variable. No session login and no CST headers, so there is less to go wrong. You do not need a wrapper library for this, and you should not reach for the most downloaded one: our library health page records oandapyV20 at 0.7.2 of August 2021, with no release since. The samples below use requests and fetch only.
developer.oanda.comchecked
First call: list your accounts
GET /v3/accounts returns every account the token can see. Note the account ID, you need it for everything else.
Python
# Set OANDA_TOKEN as an environment variable first. Never paste real values into code or into this site. import os import requests BASE_URL = "https://api-fxpractice.oanda.com" headers = { "Content-Type": "application/json", "Authorization": "Bearer " + os.environ["OANDA_TOKEN"], } resp = requests.get(BASE_URL + "/v3/accounts", headers=headers, timeout=10) resp.raise_for_status() print(resp.json())JavaScript
// Set OANDA_TOKEN as an environment variable first. Never paste real values into code or into this site. // Node 18 or later. Save as a .mjs file so top level await works. const BASE_URL = "https://api-fxpractice.oanda.com"; const res = await fetch(`${BASE_URL}/v3/accounts`, { method: "GET", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.OANDA_TOKEN}` } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log(await res.json());curl
# Set OANDA_TOKEN as an environment variable first. Never paste real values into code or into this site. curl "https://api-fxpractice.oanda.com/v3/accounts" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OANDA_TOKEN"
developer.oanda.comchecked
Second call: account summary
GET /v3/accounts/{accountID}/summary gives NAV, balance, margin available and open position counts.
Python
# Set OANDA_TOKEN, OANDA_ACCOUNT_ID as environment variables first. Never paste real values into code or into this site. import os import requests BASE_URL = "https://api-fxpractice.oanda.com" ACCOUNT_ID = os.environ["OANDA_ACCOUNT_ID"] # from the previous step headers = { "Authorization": "Bearer " + os.environ["OANDA_TOKEN"], } resp = requests.get(f"{BASE_URL}/v3/accounts/{ACCOUNT_ID}/summary", headers=headers, timeout=10) resp.raise_for_status() print(resp.json())JavaScript
// Set OANDA_TOKEN, OANDA_ACCOUNT_ID as environment variables first. Never paste real values into code or into this site. // Node 18 or later. Save as a .mjs file so top level await works. const BASE_URL = "https://api-fxpractice.oanda.com"; const ACCOUNT_ID = process.env.OANDA_ACCOUNT_ID; // from the previous step const res = await fetch(`${BASE_URL}/v3/accounts/${ACCOUNT_ID}/summary`, { method: "GET", headers: { "Authorization": `Bearer ${process.env.OANDA_TOKEN}` } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); console.log(await res.json());curl
# Set OANDA_TOKEN, OANDA_ACCOUNT_ID as environment variables first. Never paste real values into code or into this site. curl "https://api-fxpractice.oanda.com/v3/accounts/$OANDA_ACCOUNT_ID/summary" \ -H "Authorization: Bearer $OANDA_TOKEN"
developer.oanda.comchecked
Mind the limits
OANDA states limits on two pages, in different units. The Best Practices page, linked below, states no more than 2 new connections per second and no more than 100 requests per second on a persistent connection. The development guide states 120 requests per second per IP address, with HTTP 429 above it; that figure is the headline row in the limits table below, with its own source and date. Neither figure replaces the other, so reuse your connection and stay under both. This site withdrew the 120 figure on 23 September 2026 and reinstated it on 24 September 2026; the sequence is on the changes page.
developer.oanda.comchecked
From first call to first order
One Python file built from the OANDA endpoint records on this site, as the last step of this quickstart. It reads OANDA_TOKEN from the environment and OANDA_ACCOUNT_ID where set. GET /v3/accounts/{accountID}/openPositions (version v3) reads your open positions. POST /v3/accounts/{accountID}/orders (version v3) places one small demo order with every field the record marks required (order). GET /v3/accounts/{accountID}/orders/{orderSpecifier} (version v3) reads the confirmation or status. It sends at most one request every second, which stays under each request limit in the OANDA limits table below. It refuses to run against any host but https://api-fxpractice.oanda.com, and it never prints a token. Dry run is the default: save it as oanda_first_order.py, run python oanda_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 oanda_first_order.py send to send them to the demo host. The script has not been run against OANDA.
Python
#!/usr/bin/env python3 """OANDA demo API: from first call to first order. Unofficial example from Broker API Hub, not published by OANDA. Written from the OANDA endpoint records on openbrokerapi.com and not run against OANDA: the quickstart page says so. Demo host only. What it does, in order: 1. proves the token with GET /v3/accounts (version v3) 2. GET /v3/accounts/{accountID}/openPositions (version v3): reads your open positions 3. POST /v3/accounts/{accountID}/orders (version v3): places one small demo order with every field the record marks required 4. GET /v3/accounts/{accountID}/orders/{orderSpecifier} (version v3): 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 oanda_first_order.py send) to send the requests to the demo host. Before a real send, set these environment variables on your own machine: OANDA_TOKEN your personal access token for the practice account OANDA_ACCOUNT_ID the account id from GET /v3/accounts 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 requests DEMO_HOST = "https://api-fxpractice.oanda.com" BASE_URL = os.environ.get("OANDA_BASE_URL", DEMO_HOST).rstrip("/") SEND = len(sys.argv) > 1 and sys.argv[1] == "send" # Pacing, from this site's limits register for OANDA: # 120/s per IP address, HTTP 429 above it (ip; checked 3 Oct 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 = { "order": {"type": "MARKET", "instrument": "...", "units": "1", "timeInForce": "FOK", "positionFill": "DEFAULT"}, # The order request. For a market order: type MARKET, instrument, units (positive to buy, negative to sell), timeInForce FOK, positionFill DEFAULT. (required) } 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({ "Authorization": "Bearer " + env("OANDA_TOKEN"), "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 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 /v3/accounts (version v3): List every account your token can access. Start here to find your accountID. resp = request("GET", "/v3/accounts") account_id = os.environ.get("OANDA_ACCOUNT_ID") or (resp.json()["accounts"][0]["id"] if resp is not None else "<OANDA_ACCOUNT_ID>") print("accounts[0].id: " + str(account_id)) # GET /v3/accounts/{accountID}/openPositions (version v3): Positions with open trades, netted per instrument with long and short sides. resp = request("GET", "/v3/accounts/" + account_id + "/openPositions") if resp is not None: print("Open positions: " + json.dumps(resp.json())[:600]) # POST /v3/accounts/{accountID}/orders (version v3): Create an order. Market, limit, stop, market if touched and dependent orders all go through this one endpoint. Returns 201. 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", "/v3/accounts/" + account_id + "/orders", body=ORDER) ref = resp.json()["orderCreateTransaction"]["id"] if resp is not None else "<orderCreateTransaction.id from the order reply>" print("orderCreateTransaction.id: " + str(ref)) # GET /v3/accounts/{accountID}/orders/{orderSpecifier} (version v3): The details of one order on the account, whatever its state. resp = request("GET", "/v3/accounts/" + account_id + "/orders/" + str(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))developer.oanda.comchecked
OANDA limits to pace against
| Limit | Value | Scope | Source |
|---|---|---|---|
| Headline limit | 120/s per IP address, HTTP 429 above it | ip | developer.oanda.comchecked |
Sources
- developer.oanda.comchecked
- developer.oanda.comchecked
- developer.oanda.comchecked
- developer.oanda.comchecked