Broker API HubMenu

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.

Independent and unofficialChecked Code not yet run on a demo account

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

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

  7. 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

LimitValueScopeSource
Headline limit120/s per IP address, HTTP 429 above itipdeveloper.oanda.comchecked

Sources

Sections