토스증권 OpenAPI 파이썬 실전 가이드 — 공식 문서에 없는 함정 4개 (2026)

토스증권 OpenAPI는 붙이기 쉽습니다. 인증이 다섯 줄이면 끝나거든요.

문제는 그다음입니다. 문서대로 짰는데 데이터가 조용히 빠집니다. 에러도 안 납니다. 그냥 며칠치가 없습니다.

1년 넘게 돌리면서 만난 함정 넷을 코드와 함께 적어둡니다. 전부 실제 응답을 보고 확인한 것들입니다.

[30초 요약] 인증은 OAuth2 client credentials라 사용자 로그인이 필요 없습니다. 그런데 ① 페이징 커서가 작동하지 않고 ② `CLOSED`는 체결완료가 아니며 ③ 예수금은 통화별로 따로 불러야 하고 ④ 호출이 몰리면 에러 없이 빈 응답이 옵니다. 넷 다 문서에 없습니다.

먼저 무엇이 되고 무엇이 안 되나

기능 엔드포인트
계좌 목록 `GET /api/v1/accounts`
보유 종목 `GET /api/v1/holdings`
예수금·매수가능금액 `GET /api/v1/buying-power`
주문·체결 내역 `GET /api/v1/orders`

기준 주소는 `https://openapi.tossinvest.com`입니다. 조회 계열은 이 넷으로 대부분 해결되고, 시세 스트리밍 같은 건 범위 밖입니다.

인증 — 사용자 로그인이 없습니다

가장 반가운 부분입니다. OAuth2 Client Credentials 방식이라 서버끼리 주고받고 끝입니다. 리다이렉트도, 콜백 URL도 없습니다.

import os, time, requests

BASE = "https://openapi.tossinvest.com"

def get_token():
    r = requests.post(
        f"{BASE}/oauth2/token",
        data={
            "grant_type": "client_credentials",
            "client_id": os.environ["TOSS_CLIENT_ID"],
            "client_secret": os.environ["TOSS_CLIENT_SECRET"],
        },
        timeout=15,
    )
    r.raise_for_status()
    d = r.json()
    return d["access_token"], time.time() + float(d.get("expires_in", 86400))

토큰 유효기간은 하루입니다. 매 호출마다 새로 받지 말고 만료 1분 전까지 재사용하세요. 토큰 발급도 호출 수에 잡힙니다.

첫 호출 — 계좌 seq부터 얻어야 합니다

다른 엔드포인트는 전부 계좌를 헤더로 지정해야 합니다. 그래서 순서가 정해져 있습니다.

def headers(token, account_seq=None):
    h = {"Authorization": f"Bearer {token}"}
    if account_seq:
        h["X-Tossinvest-Account"] = str(account_seq)   # ← 이 헤더가 필수
    return h

accounts = requests.get(f"{BASE}/api/v1/accounts",
                        headers=headers(token), timeout=15).json()

여기서 첫 번째 시행착오가 나옵니다. 응답의 최상위 키가 문서에 명확히 안 적혀 있습니다. 실제로는 `result` 아래에 들어옵니다.

# 방어적으로 후보 키를 훑는다 — 스펙과 실제가 어긋나는 지점
def as_list(d, keys=("accounts", "items", "data", "result")):
    for k in keys:
        v = d.get(k)
        if isinstance(v, list):
            return v
        if isinstance(v, dict):
            return as_list(v, keys)
    return []

seq = next(a["accountSeq"] for a in as_list(accounts)
           if str(a.get("accountType", "BROKERAGE")).upper() == "BROKERAGE")

발급받고 한 번은 원본 JSON을 그대로 찍어보세요. 그게 문서를 읽는 것보다 빠릅니다.

함정 1 — 페이징 커서가 작동하지 않습니다

가장 크게 데인 부분입니다.

`/api/v1/orders`는 기간을 주면 최신순으로 최대 100건만 돌려줍니다. 100건을 넘으면 `hasNext: true`가 오는데, `nextCursor`는 `None`입니다. 커서가 없으니 다음 페이지를 요청할 방법이 없습니다.

문서만 보면 커서 페이징이 되는 것처럼 보입니다. 그래서 짜면, 101번째부터가 조용히 사라집니다.

해결은 커서 대신 `to` 날짜를 당기는 것입니다. 응답이 최신순이니 받은 묶음에서 가장 오래된 체결일을 다음 `to`로 씁니다.

def fetch_orders(token, seq, start, end):
    got, seen, to = [], set(), end
    while True:
        d = requests.get(f"{BASE}/api/v1/orders",
                         headers=headers(token, seq),
                         params={"status": "CLOSED", "limit": 100,
                                 "from": start, "to": to},
                         timeout=15).json()
        orders = d.get("result", {}).get("orders", [])
        if not orders:
            break
        for o in orders:
            if o["orderId"] not in seen:      # 경계일 중복 제거
                seen.add(o["orderId"]); got.append(o)
        oldest = min(o["orderDate"][:10] for o in orders)
        if oldest <= start or oldest == to:   # 더 못 당기면 종료
            break
        to = oldest
    return got

`orderId`로 중복을 거르는 게 중요합니다. 경계 날짜의 주문이 두 묶음에 겹쳐서 들어옵니다.

함정 2 — `CLOSED`는 “체결완료”가 아닙니다

이름 때문에 오해하기 딱 좋습니다. `status=CLOSED`는 종결된 주문 전부입니다. 체결된 것과 취소·거부된 것이 같이 옵니다.

이걸 모르고 집계하면 거래 건수가 부풀고, 취소된 주문이 매매 기록으로 들어갑니다.

filled = [o for o in orders
          if float(o.get("execution", {}).get("filledQuantity", 0) or 0) > 0]

실제 체결분은 `execution.filledQuantity > 0`으로 걸러야 합니다. 부분 체결도 여기서 잡힙니다.

함정 3 — 예수금은 별도 엔드포인트이고, 통화별입니다

`/holdings`에는 현금이 안 나옵니다. 종목만 옵니다. 그래서 총자산을 계산하려면 예수금을 따로 불러야 합니다.

그리고 `currency` 파라미터가 필수입니다. 원화와 달러를 각각 호출해야 합니다.

def buying_power(token, seq, currency="KRW"):
    d = requests.get(f"{BASE}/api/v1/buying-power",
                     headers=headers(token, seq),
                     params={"currency": currency}, timeout=15).json()
    v = d.get("result", {}).get("cashBuyingPower", 0)
    return float(v or 0)        # ← 문자열로 옵니다

krw = buying_power(token, seq, "KRW")
usd = buying_power(token, seq, "USD")

`cashBuyingPower`가 숫자가 아니라 문자열로 옵니다. 그대로 더하면 문자열 연결이 되거나 타입 에러가 납니다.

함정 4 — 조용한 호출 제한

가장 찾기 어려운 문제입니다.

기간을 잘게 쪼개서 연속으로 20회 넘게 호출하면, 어느 순간부터 빈 응답이 옵니다. `429`도 아니고 에러도 아닙니다. 그냥 `orders`가 빈 배열입니다.

그래서 “데이터가 없는 기간”과 “제한에 걸린 기간”이 구분되지 않습니다. 저는 이것 때문에 체결 기록에 몇 달짜리 구멍이 있는 걸 한참 뒤에 발견했습니다.

대응은 둘입니다.

for attempt in range(5):
    r = requests.get(url, headers=..., params=..., timeout=15)
    if r.status_code == 429:
        time.sleep(min(2 ** attempt, 16))   # 1,2,4,8,16초
        continue
    r.raise_for_status()
    break

`429`는 지수 백오프로 받아내고, 그보다 중요한 건 호출 수 자체를 줄이는 것입니다. 기간을 잘게 쪼개지 말고 한 번에 넓게 요청해서 8회 안팎으로 끝내는 편이 안전합니다.

그리고 수집이 끝나면 건수가 0인 구간을 따로 로그에 남기세요. 진짜 0건인지 실패인지 나중에 구분하려면 그 기록이 있어야 합니다. 이 문제는 크론이 조용히 죽는 방식과 같은 종류입니다.

자주 묻는 질문

API 이용료가 있나요? 별도 이용료는 없고, 주문 수수료는 앱과 동일합니다(국내 0.015%·미국 0.1% 수준). 조건은 바뀔 수 있으니 발급 시 확인하세요.

주문도 되나요? 됩니다. 다만 조회와 달리 실수의 대가가 크므로, 소액 1주로 왕복 검증을 먼저 하시길 권합니다.

한국투자증권 API와 비교하면요? 토스 쪽이 인증이 훨씬 단순합니다(사용자 로그인 없음). 대신 엔드포인트 수가 적습니다. 한투 쪽 함정은 KIS API 함정 모음에 따로 적었습니다.

응답 스키마가 바뀌면요? 실제로 바뀝니다. 그래서 키를 하드코딩하지 말고 위의 `as_list`처럼 후보 키를 훑는 방식을 권합니다. 저는 응답 원본을 며칠치 저장해두고 스키마 변화를 감지합니다.

토큰을 어디에 두나요? 환경변수나 `.env`에 두고 저장소에 커밋하지 마세요. 클라이언트 시크릿이 유출되면 계좌 조회가 열립니다.

함께 읽으면 좋은 글

한국투자증권 API에서 만난 함정은 KIS API 함정 모음에, 이런 수집기를 집에서 24시간 돌리는 구조는 라즈베리파이 자동화 서버에 정리했습니다.

문서에 있는 건 30분이면 붙입니다. 나머지 시간은 문서에 없는 걸 찾는 데 씁니다.

※ 본 글은 2026년 기준 실제 사용 경험이며, API 스펙과 정책은 변경될 수 있습니다. 공식 문서를 함께 확인하시기 바랍니다.

Leave a Comment