토스증권 API 주문 취소·정정 — orderId가 새로 생긴다
POST /api/v1/orders/{orderId}/cancel,
정정은 POST /api/v1/orders/{orderId}/modify입니다.
가장 많이 놓치는 지점은 응답으로 오는 orderId가 원주문과 다른 새 식별자라는 것입니다.
봇이 주문 하나를 orderId 하나로만 추적하고 있었다면
취소·정정을 넣는 순간 추적이 조용히 끊깁니다.
정정 바디는 orderType이 필수이고
국내주식은 quantity가 필수, 미국주식은 quantity를 보내면 거부됩니다.
이미 끝난 주문에 취소를 걸면 409 already-filled·already-canceled가 내려옵니다.
토스증권 첫 주문이 나가고 나면 바로 다음에 필요한 것은 낸 주문을 거두는 코드입니다. 지정가를 걸어 놨는데 가격이 달아났거나, 신호가 뒤집혔거나, 장 마감이 다가오는 상황은 매일 생깁니다.
그런데 이 부분에서 KIS나 키움 REST API 습관을 그대로 가져오면 동작은 하는데 봇의 상태 관리가 틀어지는 형태로 문제가 납니다. 에러가 안 나기 때문에 더 늦게 발견됩니다.
1. 경로 — 주문 생성과 모양이 다르다
주문 생성은 POST /api/v1/orders 하나였지만,
취소·정정은 원주문 orderId가 경로에 들어갑니다.
| 동작 | 메서드 · 경로 | 바디 |
|---|---|---|
| 주문 | POST /api/v1/orders | symbol·side·orderType·quantity… |
| 취소 | POST /api/v1/orders/{orderId}/cancel | 비어 있음 {} |
| 정정 | POST /api/v1/orders/{orderId}/modify | orderType·quantity·price |
둘 다 X-Tossinvest-Account 헤더(accountSeq)가 필요합니다.
빠지면 account-header-required가 납니다.
DELETE가 아니라 POST라는 점도 한 번씩 걸리는 자리입니다 —
DELETE를 쓰는 것은 조건주문(DELETE /api/v1/conditional-orders/{id}) 쪽입니다.
import requests
BASE = "https://openapi.tossinvest.com"
H = {"Authorization": f"Bearer {tok}", "X-Tossinvest-Account": str(seq)}
# 취소 — 바디는 비어 있다
res = requests.post(f"{BASE}/api/v1/orders/{order_id}/cancel",
headers=H, json={}, timeout=10)
# 정정 — 지정가 71,000원 · 15주로
res = requests.post(f"{BASE}/api/v1/orders/{order_id}/modify",
headers=H, timeout=10,
json={"orderType": "LIMIT", "quantity": "15", "price": "71000"})
2. 여기가 핵심 — 응답의 orderId는 원주문이 아니다
성공하면 이렇게 옵니다.
{
"result": {
"orderId": "5nfzdqmzfnAw3LFXWHPRy0UNi7y_WZlphJh5hRIsi25-NIfm_GtQgXima5QD2hUz"
}
}
orderId는 취소·정정 요청에 대해 새로 발급된 식별자입니다.
경로에 넣은 원주문 orderId와 다른 값입니다.
공식 스펙에도 "정정/취소로 새로 발급된 주문 식별자. 원주문의 orderId 와 다릅니다"라고 명시돼 있습니다.
이걸 모르고 짜면 봇에서 이런 코드가 나옵니다.
# ❌ 흔한 형태 — 취소 응답의 orderId로 원주문을 덮어쓴다
r = cancel(order_id)
order_id = r["orderId"] # 원주문 추적을 잃는다
status = get_order(order_id) # 취소 요청 레코드만 보게 된다
원주문의 최종 결과는 원주문 orderId로 조회해야 보입니다.
취소·정정으로 받은 orderId는 그 요청 자체의 레코드입니다.
둘을 같이 들고 있어야 "취소를 걸었고, 원주문은 결국 어떻게 끝났는가"를 재구성할 수 있습니다.
# ✅ 원주문과 요청 레코드를 함께 남긴다
class OrderTrack:
def __init__(self, order_id):
self.origin = order_id # 체결 판정은 항상 이 값으로
self.ops = [] # 취소·정정 요청으로 받은 orderId 들
def cancel(self):
r = api("POST", f"/api/v1/orders/{self.origin}/cancel", json={})
self.ops.append(r["orderId"])
return r
def final(self):
o = api("GET", f"/api/v1/orders/{self.origin}")
return o["status"], o["execution"]["filledQuantity"]
3. 정정 바디 — 국내와 미국이 반대다
modify의 바디에서 필수는 orderType 하나이지만,
실제로는 시장에 따라 규칙이 갈립니다.
| 필드 | 국내주식(KR) | 미국주식(US) |
|---|---|---|
orderType | 필수 — LIMIT 또는 MARKET | |
quantity | 필수 · 양의 정수 | 전달 불가 |
price | LIMIT일 때만 · MARKET이면 전달 불가 | |
| 실패 코드 | 400 invalid-request | 400 us-modify-quantity-not-supported |
국내주식에서 quantity를 빼거나 0·음수·소수점을 넣으면
400 invalid-request입니다. 미국주식은 반대로 quantity를 넣는 순간
us-modify-quantity-not-supported가 납니다.
국내·해외를 한 함수로 처리하는 코드라면 반드시 분기해야 하는 자리입니다.
def modify(order_id, market, order_type, price=None, quantity=None):
body = {"orderType": order_type}
if order_type == "LIMIT":
body["price"] = str(price) # MARKET이면 넣지 않는다
if market == "KR":
body["quantity"] = str(int(quantity)) # 국내는 필수 · 정수
# US 는 quantity 를 넣지 않는다
return api("POST", f"/api/v1/orders/{order_id}/modify", json=body)
가격을 바꾸는 정정이라면 price가 호가 단위에 맞아야 한다는 제약도 그대로 적용됩니다.
어긋나면 400 invalid-request와 함께 error.data에
tickSize·nearestPrices가 실려 옵니다.
4. 409 계열 — 실패가 아니라 "이미 끝났다"
취소·정정에서 가장 자주 만나는 응답입니다. 이 코드들을 네트워크 오류처럼 재시도하면 호출 한도만 태웁니다.
| code | 상황 | 봇 처리 |
|---|---|---|
already-filled | 이미 전량 체결 | 재시도 금지 · 체결로 확정 |
already-canceled | 이미 취소됨 | 재시도 금지 · 종료 처리 |
already-modified | 이미 정정됨 | 최신 주문을 다시 조회 |
already-rejected | 이미 거부됨 | 사유 확인 |
already-processing · request-in-progress | 직전 요청 처리 중 | 잠시 뒤 조회로 확인 (재요청 아님) |
cancel-restricted · modify-restricted | 제한된 주문 | 수동 확인 필요 |
order-not-found | 없는 orderId | 추적 데이터 점검 |
DONE = {"already-filled", "already-canceled", "already-rejected"}
try:
track.cancel()
except ApiError as e:
if e.code in DONE:
pass # 실패가 아니라 이미 끝난 상태
elif e.code in ("already-processing", "request-in-progress"):
time.sleep(1) # 재요청이 아니라 조회로 확인
else:
raise
429 rate-limit-exceeded가 났다면 그건 다른 문제입니다.
응답 헤더의 Retry-After를 읽어 쉬어야 하고,
전략을 여러 개 돌린다면 호출량이 합산된다는 점도 같이 봐야 합니다 —
호출 제한 설계에 정리해 뒀습니다.
5. 조건주문은 경로도 메서드도 다르다
OCO·OTO 같은 조건주문은 일반 주문과 완전히 다른 리소스입니다.
같은 함수로 취소하려다 order-not-found를 보고 헤매기 쉬운 자리입니다.
| 동작 | 일반 주문 | 조건주문 |
|---|---|---|
| 취소 | POST /api/v1/orders/{orderId}/cancel | DELETE /api/v1/conditional-orders/{id} |
| 정정 | POST /api/v1/orders/{orderId}/modify | POST /api/v1/conditional-orders/{id}/modify |
| 조회 | GET /api/v1/orders/{orderId} | GET /api/v1/conditional-orders/{id} |
| 없을 때 | order-not-found | conditional-order-not-found |
조건주문 취소만 DELETE라는 점을 기억하십시오.
조건주문 자체의 구성(SINGLE·OCO·OTO)은
토스증권 오픈API 허브 글에 정리돼 있습니다.
6. 상태 전이 — 거부는 별도 레코드로 생긴다
취소·정정 요청이 접수되면 원주문의 status가 먼저 대기 상태로 바뀝니다.
| status | 뜻 |
|---|---|
PENDING_CANCEL | 취소 요청 접수 · 브로커 응답 대기 |
PENDING_REPLACE | 정정 요청 접수 · 브로커 응답 대기 |
CANCELED | 취소 완료 (부분체결이 있었을 수 있음) |
REPLACED | 정정 수락 · 원주문이 대체됨 |
CANCEL_REJECTED | 취소 거부 — 별도 주문 레코드로 생성, 원주문은 이전 상태로 복귀 |
REPLACE_REJECTED | 정정 거부 — 위와 동일 |
PENDING_CANCEL인 동안 재주문을 내면 안 됩니다.
취소가 거부되면 원주문이 살아 돌아오기 때문에, 그 사이에 같은 신호로 새 주문을 냈다면
의도한 수량의 두 배가 걸립니다.
CANCELED 또는 CANCEL_REJECTED로 확정될 때까지 기다리는 편이 안전합니다.
7. 취소해도 부분체결은 남는다
CANCELED는 "하나도 안 샀다"는 뜻이 아닙니다.
일부가 체결된 뒤 잔량이 취소된 경우도 CANCELED입니다.
수량은 항상 원주문 조회의 execution.filledQuantity로 판단하십시오.
{
"orderId": "A...",
"status": "CANCELED",
"quantity": "10",
"canceledAt": "2026-08-19T10:12:04+09:00",
"execution": {
"filledQuantity": "4", # 4주는 이미 체결됐다
"averageFilledPrice": "70100",
"filledAmount": "280400",
"commission": "560",
"tax": "0",
"settlementDate": "2026-08-21"
}
}
포지션 장부를 quantity(주문 수량)로 갱신하고 있었다면 여기서 어긋납니다.
부분체결이 왜 자주 생기는지는
체결·슬리피지 글에 정리했습니다.
주문 추적·취소 재시도·부분체결 반영·재시작 복구까지 붙여 실제로 도는 상태로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
제작 상담하기 →8. KIS·키움에서 넘어왔다면
세 곳 모두 "원주문을 지정해서 취소한다"는 개념은 같지만 지정하는 방식이 다릅니다.
- 한국투자증권 KIS — 주문 취소·정정 API에 원주문번호를 바디로 넣습니다. 자세한 필드는 KIS 주문 취소·정정에 정리했습니다.
- 키움 REST API — 주문과 같은 경로에
api-id를 바꿔 보냅니다(정정kt10002·취소kt10003). 주문 응답의ord_no를 저장해 두지 않으면 손댈 수 없습니다 — 키움 주식 주문 kt10000 참고. - 토스증권 — 원주문
orderId를 경로에 넣고, 응답으로 새orderId를 받습니다.
공통점은 하나입니다. 주문번호를 메모리에만 들고 있으면 프로세스가 죽는 순간 미체결 주문이 고아가 됩니다. 발급 즉시 파일이나 DB에 남기는 편이 안전합니다.
스펙은 바뀝니다. 이 글은 발행 시점의 토스증권 Open API 공개 스펙을 따라 썼습니다. 필드·코드·한도는 공식 개발자 문서에서 한 번 더 확인하시고, 코드에 상수로 박기보다 설정으로 분리해 두십시오.
자주 묻는 질문
Q. 주문을 취소하면 orderId가 그대로인가요?
아닙니다. 취소·정정 응답의 orderId는 그 요청에 대해 새로 발급된 식별자로 원주문과 다릅니다.
최종 체결 수량은 원주문 orderId로 조회해 execution.filledQuantity를 봐야 합니다.
Q. 국내주식 정정에 무엇을 넣나요?
orderType은 필수, 국내주식은 quantity도 필수(양의 정수)입니다.
미국주식은 quantity를 보내면 400 us-modify-quantity-not-supported가 납니다.
Q. 이미 체결된 주문을 취소하면?
409 already-filled가 내려옵니다. already-canceled·already-modified·
already-rejected도 같은 계열이라 재시도 대상이 아닙니다.
Q. 취소했는데 일부만 체결됐다면?
상태가 CANCELED여도 execution.filledQuantity가 0이 아닐 수 있습니다.
포지션 장부는 이 값으로 갱신하십시오.
주문 관리까지 붙이고 싶다면
토스증권 Open API 연동과 미체결·취소·부분체결 처리를 실제 운영 가능한 형태로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기