RC Rail ConnectKTX·SRT 예약 API · 파트너 연동 가이드 v1

파트너 연동

Rail Connect 연동 가이드

파트너사가 KTX·SRT 좌석 조회부터 예약·결제·발권·취소·상태동기화까지 연동하기 위한 개발 문서입니다. 모든 요청은 발급받은 API Key 로 인증하고, 함께 받으신 Secret Key 로 서명합니다(요청 서명).

개요 · 예약 흐름 #

서비스는 파트너사의 주문 시스템을 대신해 코레일/SR에 실제 예약·결제·발권을 수행합니다. 고객 결제를 누가 받느냐에 따라 연동이 갈립니다(결제 모드).

  1. 역 목록 stations — 출발·도착 선택에 쓸 역 카탈로그를 받습니다. 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요(철도사마다 이름이 다른 역이 있습니다).
  2. 좌석 조회 availability — 구간·일자로 열차별 잔여/매진·요금을 받습니다.
  3. 좌석 선점 reserve — 미결제 상태로 좌석을 잡고 예약번호(rsvId)를 받습니다.
  4. 고객 결제Partner 파트너 화면에서 받습니다(우리 서비스는 파트너 주문을 읽지 않습니다) · RAILCONNECT checkout 으로 결제 주소를 받아 고객을 보냅니다.
  5. 결제 · 발권Partner payauthorizations 를 담아 호출 · RAILCONNECT 결제 웹훅을 받아 저희가 발권합니다(pay 호출 불필요).
  6. 취소 / 환불 cancel — 미결제는 취소, 결제분은 환불로 자동 분기합니다. RAILCONNECT 모드는 고객 환불까지 함께 처리합니다.
  7. 예매 내역 조회 bookings — 회원 식별자로 그 회원의 예매 목록·상세를 받습니다(파트너 사이트의 “내 예매 내역”).
  8. 실시간 상태 대사 sync — 예약번호로 지금 상태(유효/발권/취소)와 발권된 좌석을 확인합니다. 저희 DB 가 아니라 철도사에 직접 물어보므로 저희를 거치지 않은 변화도 잡힙니다.

발권은 파트너사 계정으로 이뤄집니다. 고객의 코레일·SRT 계정이 아니라, 콘솔에 등록한 파트너사의 철도사 계정으로 예약·결제·발권합니다. 그래서 승차권은 그 계정의 예매 내역에 들어가고, 고객은 철도사 앱에서 이 예약을 볼 수 없습니다 — 고객에게는 저희가 보내는 승차권 바우처가 전달됩니다.

같은 이유로 철도사 사이트·앱에서 그 예약을 직접 건드릴 수 있는 쪽은 계정을 가진 파트너사입니다. 그렇게 취소하면 저희 기록과 어긋나므로 sync 로 대사해야 합니다.

금액 계산(정상운임·운임할인·결제운임·발권수수료·고객 청구액)은 수수료 · 청구액에, 취소 전 환불 예상액은 환불 예상액에 있습니다.

(Partner 모드) 결제(pay)는 폴러 방식을 권장합니다. 결제 기한(코레일 10~20분) 안에 크론으로 주기 호출하면 코레일 응답 지연·브라우저 이탈에도 재시도됩니다. 체크아웃 흐름에 직접 매달지 마세요.

인증 · 라이브 게이트 #

모든 요청 헤더에 API 키를 담습니다. X-Api-Key 헤더도 허용됩니다.

HTTP
Authorization: Bearer <API_KEY>
개발용 ksd_항상 dry-run — 실제 예약·결제가 되지 않습니다. 연동 테스트용.
운영용 ksp_실제 예약·결제 수행. 아래 2단 게이트를 모두 통과해야 합니다.

2단 라이브 게이트. 실제 예약/결제/취소는 ① 요청 본문 "live": true 그리고 ② 운영용 키일 때만 수행됩니다. 하나라도 빠지면 dry-run으로 판정만 돌려줍니다(과금 없음). 실 운영 전 dry-run으로 대상·판정을 먼저 검증하세요.

요청 서명 · Secret Key #

API 키를 발급받으실 때 Secret Key 를 함께 드립니다. 모든 요청에 이 값으로 만든 서명을 실어 주세요 — 서명이 없거나 맞지 않으면 401 로 막힙니다.

API Key ksp_/ksd_ 신원 — 누구의 요청인지. Authorization 헤더로 보냅니다.
Secret Key kss_ 증명 — 서명을 만드는 데만 씁니다. 절대 보내지 마세요.

Secret Key 를 헤더에 담아 보내는 방식이 아닙니다. 그렇게 하면 API Key 와 유출 경로가 같아 하나가 새면 둘 다 샙니다. Secret Key 는 보내지 않고 서명에만 씁니다 — 네트워크·프록시·로그 어디에도 남지 않고, 서명에 시각이 들어가 가로챈 요청을 그대로 재전송해도 통하지 않습니다.

헤더 세 개

HTTP
Authorization: Bearer <API_KEY>
X-Timestamp: 1755100800
X-Signature: sha256=<hex>

서명 대상(정본 문자열)

아래 넷을 줄바꿈(\n)으로 이어 붙인 문자열을 시크릿으로 HMAC-SHA256 한 뒤, 16진수로 적고 앞에 sha256= 를 붙입니다.

TEXT
POST
/api/booking/reserve
1755100800
<본문을 sha256 한 16진수>
메서드항상 POST (대문자).
경로/api/booking/…쿼리스트링과 도메인은 뺍니다.
시각유닉스 (밀리초 아님). X-Timestamp같은 값이어야 합니다.
본문 해시실제로 보내는 바이트를 sha256 한 값. 본문이 없으면 빈 문자열의 sha256.

본문은 한 번만 직렬화하세요. 서명할 때와 보낼 때 JSON.stringify·json.dumps 를 각각 부르면 공백이나 키 순서가 달라져 서명이 어긋납니다 — 서명 실패의 가장 흔한 원인입니다. 먼저 문자열/바이트를 만들고, 그것을 서명한 뒤 그대로 전송하세요.

예제

Python
import hashlib, hmac, json, time, requests

API_KEY = "ksp_..."
SECRET  = "kss_..."
BASE    = "https://rail-connect.rideus.net"
PATH    = "/api/booking/reserve"

body = json.dumps({"depName": "서울", "arrName": "부산"}).encode()  # 한 번만 만든다
ts   = str(int(time.time()))
msg  = "\n".join(["POST", PATH, ts, hashlib.sha256(body).hexdigest()]).encode()
sig  = "sha256=" + hmac.new(SECRET.encode(), msg, hashlib.sha256).hexdigest()

r = requests.post(BASE + PATH, data=body, headers={
    "Authorization": "Bearer " + API_KEY,
    "Content-Type": "application/json; charset=utf-8",
    "X-Timestamp": ts,
    "X-Signature": sig,
})

실패했을 때

상태응답 error원인
401서명 헤더가 없습니다…X-Timestamp 또는 X-Signature 누락.
401요청 시각이 N초 어긋났습니다…서버 시계 오차. 허용은 ±5분 — NTP 를 맞추세요. 밀리초를 보낸 경우도 여기로 옵니다.
401서명이 맞지 않습니다…시크릿·본문·경로 중 하나가 다름. 대개 본문을 두 번 직렬화한 경우입니다.

Secret Key 는 발급 직후 한 번만 보입니다. 콘솔이 다시 보여 주지 않으니(봉인 저장) 발급받으신 값을 안전한 곳에 보관하세요. 분실하면 재발급해 드릴 수 있지만, 재발급 즉시 옛 값으로 만든 서명은 통하지 않습니다 — 반영하시는 동안 요청이 401 로 막힙니다.

공통 규약 #

기본 주소

아래 모든 경로는 이 주소 뒤에 붙습니다. 메서드는 전부 POST입니다.

BASE_URL · https://rail-connect.rideus.net

테스트와 운영은 주소가 같습니다. 어느 쪽으로 동작할지는 키 종류가 정합니다(ksd_ 개발용 / ksp_ 운영용). 별도의 스테이징 도메인은 없습니다.

HTTP
POST https://rail-connect.rideus.net/api/booking/reserve
Authorization: Bearer <API_KEY>
Content-Type: application/json; charset=utf-8
X-Timestamp: <유닉스 초>
X-Signature: sha256=<hex>

뒤 두 줄은 요청 서명입니다 — Secret Key 로 만들며, 빠지면 401 입니다.

테스트와 운영은 주소가 같습니다 — 어느 쪽으로 동작할지는 키 종류가 정합니다(ksd_ 개발용 / ksp_ 운영용). 별도의 스테이징 도메인은 없습니다.

  • 요청·응답 모두 application/json; charset=utf-8.
  • 날짜 YYYYMMDD, 시각 HHmm 또는 HHmmss. 금액은 원 단위 정수(소수점 없음).
  • 응답 공통 봉투 { ok, stage, ... }. 실패 시 { ok:false, code, stage, error } — 분기는 오류 코드(code)로 하세요.

HTTP 상태코드

상태코드로 분기하지 마세요 — okcode 로 판단하세요. 성공은 언제나 200 + ok:true 이지만, 실패 코드는 엔드포인트마다 다릅니다. 예를 들어 매진(SOLD_OUT)은 reserve 에서 502 로 옵니다 — 5xx 를 "서버 장애"로 읽고 재시도하면 매진된 열차를 계속 두드리게 됩니다.

availability2026-08-27부터 코드에 맞는 상태로 답합니다. 예전에는 모든 실패가 502 라, 예매 구간 밖 날짜를 고른 것뿐인데 파트너 화면이 “예매 서버가 죽었다”로 읽는 일이 있었습니다. 다른 엔드포인트는 아직 예전 그대로이니 여전히 code 로 판단하세요.

상태언제
200성공(ok:true). pay 는 예약별로 갈리므로 results[].code 도 함께 보세요.
400본문 형식·필수값 오류(모든 엔드포인트). 그리고 checkout·pay·bookings·refund-quote모든 실패가 400입니다.
401API 키 없음·무효. 요청 서명을 켠 키라면 서명 누락·불일치도 여기로 옵니다.
403등록하지 않은 오리진에서의 브라우저 호출(CORS).
500pay 처리 중 서버 오류.
409availability 의 매진(SOLD_OUT).
502reserve·cancel·sync모든 실패(매진·미조회 포함). availability2026-08-27부터 코드에 맞는 상태로 답합니다 — 날짜·입력 문제는 400, 매진은 409, 철도사 쪽 문제일 때만 502.

코레일 vs SRT 라우팅. reserve·cancelservice("korail" | "srt")로 명시하거나, 생략 시 trainGradeName"SRT…"로 시작하면 SRT로 판단합니다. paycarrier로 한 철도사만 지정할 수 있고, 생략하면 코레일·SRT를 모두 처리합니다.

⚠️ 2026-09-01 KTX·SRT 통합운행 이후 reserve 는 SRT 를 받지 않습니다 (SRT_DISCONTINUED). SRT 로 팔리는 열차가 없어져 좌석을 잡을 수 없기 때문입니다 — 같은 구간을 KTX 로 예약하세요. cancel 은 그대로 SRT 를 받습니다: 통합 전에 잡아 둔 예약의 취소·환불이 막히면 안 되기 때문입니다.

역 목록 #

POST/api/booking/stations

출발·도착 선택 화면에 쓰는 역 카탈로그입니다. 원본은 공공데이터(TAGO)이고 하루 한 번 다시 받습니다 — 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요.

요청결과무엇
{}344전체(무궁화·ITX 정차역 포함)
{"trainTypes":["KTX"]}62KTX 가 서는 역
{"trainTypes":["SRT"]}0⚠️ 2026-09-01 통합운행으로 값이 없어졌습니다 (아래 참고)
{"carrier":"korail"}343코레일이 취급하는 역 — KTX 와 다릅니다
{"carrier":"srt"}32저희가 SRT 로 예매할 수 있는

trainTypesq·carrier 는 함께 쓸 수 있습니다(모두 만족하는 역만 옵니다). trainTypes 안의 값끼리는 합집합입니다 — “둘 다 서는 역”이 아니라 “하나라도 서는 역”입니다.

응답 필드

필드타입설명
namestring역 이름. 호출할 때는 언제나 이 한국어 이름을 보냅니다.
citystring소재 시·도.
tagoIdstring공공데이터 역 코드로, 다른 공공 API 와 맞출 때 쓰십시오.
carriersstring[]저희가 예매할 수 있는 철도사. 예매 가능 여부로 거르려면 이 값을 보세요.
trainTypesstring[]그 역에 서는 열차 종류 — carriers 와 다릅니다(아래 경고).
carrierNamesobject참고용 — 파트너는 name 만 쓰면 됩니다(우리가 바꿔 넘깁니다).
namesobject화면 표시용 다국어(en·ja·zh-CN·zh-TW). 모르는 역은 빈 객체 {} 입니다.

name 을 그대로 availability·reserve 에 넘기세요. 철도사마다 이름이 다른 역이 있는데(울산 ↔ 코레일·SR 은 울산(통도사), 김천구미 ↔ SR 은 김천(구미)) 저희가 바꿔서 넘깁니다.

carriers 로 어느 철도사가 서는지 알 수 있습니다 — SR 전용역(평택지제)과 코레일 전용역(용산·서울 등)이 있어, 구간을 고를 때 걸러 주면 헛조회가 줄어듭니다.

⚠️ carrierstrainTypes 는 다릅니다. korail 은 “코레일이 취급하는 역”이라 무궁화만 서는 역까지 포함합니다(343개역 전부). KTX 만 파는 화면이라면 trainTypes: ["KTX"] 로 걸러야 합니다 — carrier: "korail" 로 거르면 KTX 가 서지 않는 역이 목록에 뜹니다.

⚠️ 2026-09-01 KTX·SRT 통합운행 — trainTypes 에서 "SRT" 가 사라졌습니다. SRT 로 팔리던 열차가 전부 KTX 로 넘어왔습니다(그날 수서→부산이 SRT 34편 → 0편). 그래서 수서·동탄·평택지제·남원 같은 옛 SRT 역은 이제 trainTypes: ["KTX"] 로 옵니다.

필터를 ["SRT"] 로 걸고 계셨다면 ["KTX"] 로 바꿔 주세요. 그대로 두시면 역 목록이 0개로 옵니다. 응답의 배열 모양은 그대로라 파싱 코드는 고치실 것이 없습니다.

현재 KTX 62개역입니다(통합으로 5개역이 늘었습니다). 코레일 공식 역 안내를 그대로 쓰며 저희가 추측으로 채우지 않습니다. carriers"srt"남아 있습니다 — 통합 전에 잡힌 SRT 예약의 취소·환불 경로라, 새 예매와는 무관합니다. tagoId 는 공공데이터 역 코드로, 다른 공공 API 와 맞출 때 쓰십시오.

표기가 조금 달라도 찾습니다(여수엑스포여수EXPO). 목록에 아예 없는 이름이면 조회가 빈 목록이 아니라 INVALID_REQUEST 로 떨어집니다.

다국어 표기 — 각 역에 names 가 함께 옵니다 (en·ja·zh-CN·zh-TW). 위 응답 예시 참고.

모르는 역은 빈 객체({})입니다 — 로마자를 기계로 지어내지 않습니다. 고유명사는 공식 표기가 따로 있고, 지어낸 이름은 고객이 역 전광판과 대조하지 못합니다. KTX 주요역은 모두 들어 있습니다(옛 SRT 정차역 32곳 포함).

⚠️ 호출할 때는 언제나 한국어 name 을 보내세요. names화면에 보여 주기 위한 값이고, 조회·예약의 열쇠가 아닙니다.

JSON
{}                            // 전체
{ "q": "울산" }                // 이름으로 검색(자동완성)
{ "trainTypes": ["KTX"] }     // KTX 정차역만
                              // ⚠️ ["SRT"] 는 2026-09-01 통합운행 뒤 0개역입니다
{ "carrier": "srt" }          // SR 예매 코드가 있는 역 (옛 예약의 취소·환불 경로)

좌석 조회 #

POST/api/booking/availability

구간·일자의 열차별 잔여/매진 상태와 요금을 조회합니다. 코레일·SRT를 함께 조회해 합칩니다.

요청 필드

필드타입설명
depName · arrNamestring
필수
역 목록의 name 을 그대로.
date · timestring
필수
YYYYMMDD · HHmm. time그 시각 이후만 받는 하한입니다.
passengersnumber
권장
예매하려는 인원. 좌석현황을 이 인원 기준으로 받습니다.
paxBreakdownobject
권장
인원 구분(adults·children·seniors·toddlers). 이것만 보내도 됩니다(합으로 셉니다).
langenum
선택
화면 언어 — 통화가 갈립니다.
trainTypesstring[]
선택
받을 열차 종류. 비우면 전부(무궁화·ITX 포함). "KTX" 는 KTX-산천·KTX-이음까지 포함합니다.

인원을 보내지 않으면 좌석현황은 1인 기준입니다. 2명을 태우려는데 1석만 남은 열차도 reservePossible:"Y" 로 옵니다 — 그대로 예약을 걸면 reserve 에서 SOLD_OUT 으로 떨어집니다. passengers(또는 paxBreakdown)를 함께 보내면 코레일이 그 인원이 함께 앉을 수 있을 때만 예약가능으로 내려 줍니다.

코레일·SRT 모두 반영됩니다. 응답의 seatBasis 가 무슨 기준으로 조회했는지 알려 줍니다. 다만 잔여가 조회와 예약 사이에 바뀔 수 있으니, reserveSOLD_OUT정상 흐름으로 다뤄야 합니다.

paxBreakdown 만 보내도 됩니다(합으로 셉니다). 유아(toddlers)는 좌석을 차지하지 않아 세지 않습니다.

응답 필드

필드타입설명
customerTotalnumber일반실: 고객에게 보여 줄 금액(운임 + 발권수수료).
specialTotalnumber특실: 고객에게 보여 줄 금액. 특실이 없는 열차는 0 입니다.
generalSeatStatus
specialSeatStatus
enum등급별 상태. available | soldout | none — 좌석코드 대신 이 값으로 그리세요.
discountRatenumber철도사가 밝힌 할인율(%) — 할인이 없으면 0. 저희가 계산한 값이 아니라 받은 값입니다(코레일은 예약안내 문구, SR 은 trainDiscGenRt). 일반실 운임 기준입니다.
listFare · discountnumber일반실 정상운임 · 운임할인. listFare − discount = generalFare 가 항상 맞습니다. 철도사가 정가를 주지 않아 저희가 되짚은 값입니다.
specialListFare
specialDiscount
number특실 정상운임 · 운임할인 — 추가요금에는 할인이 안 붙습니다.
generalFarenumber철도사 일반실 운임 — 참고용(수수료 빠짐).
firstSurchargenumber특실 추가요금 — 참고용.
ticketFee
specialTicketFee
number각 등급의 발권수수료 — 내역을 따로 적을 때만.
depDate · depTime
arrDate · arrTime
string출발·도착 일시. 자정을 넘기는 열차는 날짜가 다릅니다.
trainTypeNamestringKTX · KTX-산천 · SRT · ITX-새마을 · ITX-마음 · 무궁화호 · S-train 등.
reservePossibleenum"Y"둘 중 하나라도 가능하다는 뜻 — 행 전체를 흐리게 할 때만 쓰세요.
seatBasisobject좌석현황이 몇 명 기준인지 — 코레일·SRT 모두 보낸 인원을 반영합니다.
sourcesobject어느 철도사를 실제로 물어봤는지. ok | timeout | error | skipped | off.
ticketFeeRate · feeBasisnumber
string
발권 수수료율과 그 기준. 조회 금액은 1인 일반실 기준입니다.

정상운임 · 운임할인

정상운임을 직접 계산하지 마세요. 결제운임 ÷ (1 − 할인율) 로 역산하면 소수가 나옵니다 — 15% 할인 50,800원을 역산하면 59,764.706원 이 되지만 실제 정상운임은 59,800원(할인 9,000원)입니다. 철도사 운임은 100원 단위라 나눗셈으로는 복원되지 않습니다.

listFare(정상운임)·discount(운임할인)를 저희가 함께 내려 드립니다. listFare − discount = generalFare 가 항상 맞습니다. reserve 응답에도 같은 값이 옵니다.

특실도 함께 드립니다(specialListFare·specialDiscount). 철도사는 일반실 기준 정가 하나만 주므로 특실 정가는 일반실 정가 + 특실 추가요금 으로 만듭니다 — 추가요금에는 할인이 붙지 않습니다. 그래서 할인 금액은 두 등급이 같고, specialListFare − specialDiscount = specialFare 가 맞습니다.

discountRate일반실 운임 기준입니다. 특실은 할인 금액이 같고 기준 금액이 커서 실질 할인율이 낮습니다 — 화면에 비율을 적으신다면 specialDiscount ÷ specialListFare 로 따로 계산하세요.

적립은 할인이 아닙니다. 안내 문구가 "5%적립" 이면 요금은 그대로고 포인트만 쌓이는 것이라 discountRate: 0 · discount: 0 으로 옵니다 (listFare 는 판매가와 같습니다). 할인은 "25%할인" 처럼 “할인”이 붙은 문구일 때만 잡힙니다.

드물게 되짚지 못하면 listFareExact: false 가 함께 옵니다(요금제가 바뀐 경우). 그때는 근사값이라 청구 근거로 쓰지 마세요 — 청구는 언제나 customerTotal 입니다.

화면에 보여 줄 금액

customerTotal 을 보여 주세요. generalFare발권수수료가 빠진 철도사 운임이라, 그대로 띄우면 결제창 금액이 갑자기 커집니다 — 수수료가 10%면 화면엔 7,500원, 결제창엔 8,250원이 뜹니다. 실제로 이렇게 어긋난 적이 있습니다.

특실은 specialTotal 입니다. customerTotal + firstSurcharge 로 만들지 마세요 — 추가요금에 붙는 수수료가 빠집니다. 수수료는 특실 운임 전체(일반실 + 추가요금)에 붙습니다.

달러 화면도 같습니다 — usd.customerTotal · usd.specialTotal.

필드무엇화면에
customerTotal일반실 운임 + 발권수수료 = 고객이 낼 금액✅ 일반실
specialTotal특실 운임 + 발권수수료 = 고객이 낼 금액✅ 특실
generalFare철도사 일반실 운임(수수료 빠짐)참고용
firstSurcharge특실 추가요금(일반실 대비)참고용
specialFare특실 운임 = generalFare + firstSurcharge참고용
ticketFee · specialTicketFee각 등급의 발권수수료 — 내역을 따로 적을 때만참고용
예 · 수수료 10%
일반실  53,800 + 5,380 = 59,180원   ← customerTotal
특실   (53,800+23,900) + 7,770 = 85,470원   ← specialTotal
        ✗ 59,180 + 23,900 = 83,080원  (추가요금 몫 수수료 2,390원이 빠짐)

특실이 없는 열차(specialSeatStatus: "none")는 specialFare·specialTotal0 입니다 — 일반실 값을 넣어 두면 특실 없는 무궁화호에 특실 요금이 뜨기 때문입니다.

조회 금액은 1인 일반실 기준입니다(feeBasis 에도 적혀 있습니다). 인원이 2명 이상이거나 특실을 고르면 달라집니다.

확정 금액은 reserve 응답, 최종 결제 금액은 checkout 응답의 chargeAmount 입니다. 결제 직전 화면에는 chargeAmount 를 그대로 보여 주세요 — 조회 값으로 결제 화면을 그리면 인원·등급만큼 어긋납니다.

고를 수 있는 날짜에는 끝이 있습니다 — 그런데 며칠 뒤인지는 고정이 아닙니다. 철도사가 기간을 나눠 열고, 명절 특별수송기간이 앞에 걸리면 그만큼 당겨집니다. (2026-08-27 실측: 코레일·SRT 모두 9/22 까지 조회됐고 9/23 부터는 추석 안내가 나왔습니다.)

그래서 “오늘+N일”로 달력을 막지 마세요 — 내일 틀립니다. 범위 밖 날짜는 조회가 아래 코드로 떨어지므로, 그 코드를 받아 화면에서 안내하시는 편이 정확합니다.

code고객에게 할 말
DATE_NOT_OPEN아직 예매가 열리지 않은 날짜입니다. 언제 열리는지는 철도사도 알려 주지 않습니다.
DATE_SPECIAL_PERIOD명절 특별수송기간이라 예매일이 따로 있습니다. error 에 철도사 안내문이 그대로 오니 그대로 보여 주셔도 됩니다.

둘 다 다시 불러도 같은 결과입니다 — 조회가 실패한 것이 아니라 그 날짜를 아직 팔지 않는 것입니다. 폴링하지 마세요.

HTTP 상태는 400 입니다(2026-08-27부터). 예전에는 조회 실패를 전부 502 로 보내, 파트너 화면이 “예매 서버가 죽었다”로 읽고 “잠시 후 다시 시도” 안내를 띄웠습니다. 5xx 는 저희 쪽이 고장난 것일 때만 옵니다 — 철도사 로그인 실패·응답 없음 같은 경우입니다.

철도사 안내문을 화면에 그릴 때

DATE_SPECIAL_PERIODerror 에는 철도사 안내문이 그대로 옵니다 — 예매 일정·대상·자격 조건이 적힌 여러 줄짜리 글입니다.

JSON
{ "ok": false, "code": "DATE_SPECIAL_PERIOD", "stage": "search",
  "error": "2026년 추석 특별수송기간 …합니다.\n\n1. 대상열차 : …\n2. 예매일자 : …" }

줄바꿈은 저희가 보냅니다. 값 안에 \n 이 그대로 들어 있습니다 (위 안내문은 8개). 한 줄로 보인다면 HTML 이 줄바꿈을 공백으로 접기 때문이며, CSS 한 줄로 살릴 수 있습니다.

CSS
white-space: pre-line;

innerHTMLerror 를 그대로 넣지 마세요. 철도사가 주는 문자열이라 내용을 저희가 통제하지 못합니다. textContent 로 넣으시거나, 이스케이프한 뒤 \n<br> 로 바꿔 주세요.

이 안내문은 한국어로만 옵니다

저희가 만든 문구가 아니라 철도사 응답 원문이고, 철도사가 영어판을 주지 않습니다. 저희가 번역해 드리지도 않습니다 — 내용이 날짜 · 대상 · 자격 조건 이라(사전예매 기간, 경로·장애인·임산부·국가유공자 한정 등) 잘못 옮기면 고객이 예매 기회를 놓치고, 문구는 명절마다 바뀌어 미리 번역해 둘 수도 없습니다.

권해 드리는 방식code 로 분기해 파트너사 언어로 안내하고, 철도사 원문은 접어서 함께 보여 주는 것입니다.

TEXT
DATE_SPECIAL_PERIOD
 → (자체 문구) "This date falls in a holiday special sales period.
                Reservations open on a separate schedule."
 → (원문, 접어두기) 2026년 추석 특별수송기간 …

코드를 나눠 드린 이유가 이것입니다 — 번역할 수 있는 것(코드)번역하면 안 되는 것(날짜·자격 조건) 을 가르기 위해서입니다.

목록을 그리는 법

요청 시각 이후 그 날의 시간표 전체가 옵니다 — 매진 열차도 포함합니다. 화면에서 빼지 말고 매진으로 그리세요(고객이 "왜 이 열차가 안 보이지"를 묻지 않게).

등급마다 상태가 따로 옵니다. 좌석코드를 해석하지 말고 아래 값을 그대로 쓰세요 — 매진 코드가 캐리어마다 다릅니다(코레일 13, SRT 12).

화면
"available"요금을 보여 주고 선택 가능
"soldout"매진
"none"그 등급이 없는 열차 — 칸을 비우거나 . 매진으로 그리면 거짓말입니다(특실 없는 무궁화호를 '특실 매진'으로 보여 주게 됩니다)
JS
일반실 = generalSeatStatus === "available" ? generalFare + "원" : (generalSeatStatus === "soldout" ? "매진" : "—")
특실   = specialSeatStatus === "available" ? (generalFare + firstSurcharge) + "원" : (specialSeatStatus === "soldout" ? "매진" : "—")

reservePossible: "Y"둘 중 하나라도 가능하다는 뜻입니다 — 행 전체를 흐리게 처리할 때만 쓰고, 등급별 표시에는 위 값을 쓰세요. SRT는 trainTypeName:"SRT"로 옵니다. 특실 요금 = generalFare + firstSurcharge.

같은 시각에 열차번호만 다른 행이 두 개 오는 일이 있습니다(예: 509·9509) — 중련 운행이라 실제로 별개 열차입니다. 합치지 말고 그대로 보여 주세요.

목록을 걸러 쓰기

출발시간·매진 제외·열차 종류 같은 걸러내기와 정렬은 파트너 화면에서 하시면 됩니다. 응답에 필요한 값이 다 실려 있습니다.

거르고 싶은 것쓰는 값
출발 시간대depTime(HHmmss) · depDate. 요청의 time그 시각 이후만 받는 하한입니다
도착 시간 · 소요시간arrTime · arrDate (자정을 넘기면 날짜가 다릅니다 — 뺄셈만 하면 음수가 됩니다)
매진 제외generalSeatStatus · specialSeatStatus (available 만 남기기)
열차 종류trainTypeName — KTX · KTX-산천 · SRT · ITX-새마을 · ITX-마음 · 무궁화호 · S-train 등
요금대generalFare · firstSurcharge · customerTotal(고객 청구액)
할인 여부discountRate

목록은 출발 시각 오름차순으로 옵니다 — 코레일과 SRT 를 따로 조회해 합치지만 저희가 정렬해 보냅니다.

어느 철도사를 물어봤는지

응답의 sources 로 알 수 있습니다.

JSON
"sources": { "korail": "ok", "srt": "off" }  // ok | timeout | error | skipped | off

2026-09-01 통합운행 뒤로는 "srt": "ok" 에 SRT 행이 없는 것이 정상입니다 — 옛 SRT 열차가 전부 코레일 쪽에서 KTX 로 옵니다. 아래는 그 전부터 있던 구분이며, timeout·error"열차가 없다"로 읽지 않기 위해 남겨 둡니다.

  • "srt": "ok" 인데 SRT 행이 없다 → 그 구간·날짜에 실제로 없습니다. SR 은 코레일보다 예약 오픈이 짧습니다 — 먼 날짜는 아직 안 열려 0편입니다 (2026-08-13 실측: 08-27 까지는 나오고 09-01 부터 0편, 같은 날 코레일은 39편).
  • "timeout" → SR 접속 대기열로 제때 못 받았습니다. 다시 부르면 나올 수 있습니다.
  • "skipped" → SR 이 서지 않는 역이라 묻지 않았습니다(용산·서울 등).
  • "off"이 키의 SRT 조회가 꺼져 있습니다. 2026-09-01 통합운행으로 SRT 로 팔리는 열차가 없어져 기본이 꺼짐입니다 — 켜면 조회마다 SR 로그인이 한 번씩 붙는데 대개 0편이라, 시도만 쌓여 계정이 잠길 위험이 있습니다. 다시 필요하시면 저희에게 말씀해 주세요(콘솔에서 키별로 켭니다).

왕복

왕복은 가는 편과 오는 편을 각각 호출합니다 — 이 엔드포인트는 한 방향만 봅니다. 결제는 checkoutlegs한 번에 묶을 수 있습니다 (고객이 두 번 결제하지 않습니다).

열차 종류를 걸러 받을 수 있습니다. 요청에 "trainTypes": ["KTX"] 를 넣으면 그 종류만 옵니다(무궁화·ITX 제외). 비우면 전부 옵니다 — 지금까지와 같습니다. "KTX"KTX-산천·KTX-이음까지 포함합니다(앞부분으로 맞춥니다). 응답을 받아 직접 거르셔도 되고, 이 값을 쓰면 주고받는 양이 줄어듭니다.

⚠️ "SRT" 는 넣지 마세요 — 2026-09-01 통합운행 뒤로 걸리는 열차가 없습니다. 옛 SRT 노선의 열차는 이제 KTX·KTX-산천 이라는 이름으로 옵니다.

JSON
{ "depName": "서울", "arrName": "부산", "date": "20260810", "time": "0900",
  "passengers": 2,
  "paxBreakdown": { "adults": 1, "children": 1 },
  "lang": "en",
  "trainTypes": ["KTX"] }

좌석 선점 #

POST/api/booking/reserve

좌석을 미결제 상태로 잡고 예약번호(rsvId)를 돌려줍니다. 이후 결제 기한 안에 pay로 결제해야 유지됩니다.

siteName 은 필수입니다. 예약을 받은 사이트 이름을 보내 주세요 — 한 파트너가 사이트를 여러 개 운영하면 파트너명만으로는 어디서 온 예약인지 알 수 없습니다.

이 값은 승차권 바우처의 “예매처” 항목과 저희 콘솔의 예매 목록·예약 세션에 그대로 표시됩니다. 바우처 꼬리말이 “변경·취소는 구매하신 사이트에서 진행해 주세요”라고 안내하는데, 그 사이트가 어디인지 적히지 않으면 고객은 갈 곳을 모릅니다. 발권 취소 안내 메일에도 함께 나갑니다.

바우처
예매처   APHRS 2026 Transport Portal  [바로가기]

siteUrl 은 선택이지만 넣어 주시길 권합니다 — 바우처의 [바로가기] 버튼과 콘솔의 링크가 됩니다(없으면 이름만 표시). http:// 또는 https:// 로 시작해야 하며, 아니면 INVALID_REQUEST 로 거절합니다.

요청 필드

필드타입설명
siteNamestring
필수
예약을 받은 파트너 사이트 — 바우처·콘솔에 표시됩니다.
siteUrlstring
권장
고객 안내에 링크로 씁니다.
depName · arrNamestring
필수
조회에 쓴 것과 같은 값을 그대로 넘기면 됩니다(stationsname). 철도사가 부르는 이름이 다르면 저희가 바꿔 넘깁니다 — 파트너가 캐리어별 이름을 구분할 필요는 없습니다.
date · timestring
필수
YYYYMMDD · HHmm. 어느 열차를 잡을지는 trainNo 가 정합니다time 은 조회에 쓴 시각을 그대로 넘기셔도 되고, 그 열차의 출발 시각을 넘기셔도 됩니다. 어느 쪽이든 같은 열차를 잡습니다.
trainNostring
필수
좌석 조회 응답의 열차번호를 그대로 넘기세요. 이 값이 열차를 특정하는 열쇠입니다. 앞의 0 은 있어도 없어도 같습니다(001 = 1).
seatTypeenum
필수
"standard"(일반실) | "first"(특실)
passengers
paxBreakdown
number
object

필수
인원과 그 구분. 유아(toddlers)는 좌석을 차지하지 않아 세지 않습니다.
serviceenum
선택
생략 시 trainGradeName 으로 추론.
liveboolean
쓰지 않음
더 보지 않습니다(2026-08-27부터). 실거래 여부는 콘솔의 라이브 게이트가 정합니다 — 아래 안내를 보세요. 보내셔도 무시되며, 오류가 나지는 않습니다.
customerKeystring
권장
파트너사의 회원 식별자입니다. 예약번호는 철도사가 발급해 파트너 회원과 이어지지 않으므로, 이 값을 보내 두면 bookings“이 회원의 예매 내역”을 바로 받을 수 있습니다. 개인정보를 담지 마세요 — 이름·연락처가 아니라 내부 회원 ID 같은 값이면 됩니다. 비회원 예매라 회원키가 없어도 됩니다 — 그때는 email·phone 으로 조회합니다(그래서 예약자 정보를 보내 두는 편이 좋습니다).
lastName · firstName
email · phone
phoneCountry · country
string
권장
예약자. 예약자·인원 정보는 예약 세션에 그대로 저장되어 콘솔 데이터 > 예매 목록에서 조회됩니다. 안 보내면 그 자리가 비며, 나중에 채울 방법이 없습니다. email 이 없으면 승차권 바우처가 발송되지 않습니다. 국가번호는 phone 에 붙여 보내도 됩니다.
langenum
권장
고객이 보고 있던 화면의 언어입니다 — 승차권 바우처 메일을 이 언어로 보냅니다. 지원: ko en ja zh-CN zh-TW es fr de ru it vi th id ar(ko-KR·EN·zh_TW 처럼 표기가 달라도 받습니다).
보내지 않으면 저희가 아는 방법이 없습니다. 그때는 콘솔에 등록된 키의 바우처 기본 언어로 나가고, 그것도 없으면 한국어입니다. 국가·전화번호로 추측하지 않습니다 — 재미교포는 국적이 KR 이면서 영어를 읽고, 한국 번호를 쓰는 외국인도 많습니다. 틀리면 고객이 못 읽는 언어로 승차권을 받는데 그건 조용히 실패합니다(개찰구에서야 드러납니다).
다국어 사이트를 굴리신다면 화면 언어를 그대로 실어 주세요. 단일 언어 사이트면 콘솔에서 기본 언어를 한 번 정해 두시면 됩니다.
바우처 메일의 역·열차 이름은 그 언어로 옮기되 한국어를 함께 적습니다 (Yongsan (용산) · KTX-Sancheon (KTX-산천)) — 전광판이 한국어로만 나오므로 옮긴 이름만 주면 고객이 승강장을 대조하지 못합니다. 열차 이름은 국문·영문 두 가지이고 그 밖의 언어는 영문으로 나갑니다(대부분 이미 로마자라 지어낸 표기가 오히려 방해입니다). API 응답의 이름은 옮기지 않습니다 — 철도사가 준 원문 그대로입니다.

실거래인지 아닌지는 저희 콘솔이 정합니다 — 요청에 넣으실 것이 없습니다. 예전에는 요청의 live 플래그도 함께 켜야 했는데, 그 사실이 문서에 없어 “운영 키인데 왜 좌석이 안 잡히느냐”로 헤매는 일이 있었습니다(2026-08-27 정리).

키 종류동작
test 키언제나 dry-run 입니다 — 게이트와 무관합니다. 화면 개발·테스트는 이 키로 하세요. 좌석이 잡히지 않습니다.
live 키콘솔의 라이브 게이트(API 키 상세)가 켜져 있으면 실제 좌석을 잡습니다. 꺼져 있으면 dry-run 입니다.

⚠️ 개발 환경에 운영 키를 두지 마세요. 버튼을 누를 때마다 철도사에 진짜 좌석이 잡히고, 결제하지 않으면 기한이 지나 풀릴 때까지 그 좌석을 붙들고 있습니다.

응답의 mode 로 어느 쪽인지 알 수 있습니다 — "live" 면 실제 예약(rsvId 가 옵니다), "dry" 면 시험입니다(rsvId 가 없어 결제로 넘어갈 수 없습니다). liveAllowed저희 게이트가 열려 있는지를 알려 줍니다 — false 면 콘솔에서 켜 달라고 요청해 주세요.

응답 필드

필드타입설명
rsvIdstring이후 모든 호출에 쓰는 예약번호.
customerTotal
ticketFee · ticketFeeRate
number고객에게 청구할 확정 금액. 요율을 따로 곱하지 말고 이 값을 그대로 쓰세요.
currency · fxRate · usdstring
number
object
lang 이 한국어가 아니면 환산액이 함께 옵니다(통화 · 환율).
train.pricenumber예약 총액(인원·등급 반영) — 수수료 계산 기준.
reservation
.buy_limit_date · _time
string결제 기한입니다(코레일 기준 10~20분). 그 전에 결제하지 않으면 좌석이 풀립니다 — Partner 모드는 이 시각까지 pay 를, RAILCONNECT 모드는 고객이 결제창을 마쳐야 합니다.
reservation.seat_no
.seat_no_count
string
number
잡힌 좌석과 그 수.
reservation.rsv_idstring최상위 rsvId같은 값입니다 — 예전 형태를 읽는 연동을 위해 남겨 둔 것이니, 새로 붙이신다면 최상위 rsvId 를 쓰세요.

여러 계정에 순차 재시도합니다. rsvId를 보관해 이후 checkout/pay/cancel/sync/bookings에 그대로 넘기세요. dry-run이면 mode:"dry-run"으로 실제 좌석은 잡지 않습니다(rsvId도 없습니다).

JSON
{
  "siteName": "RIDEUS",
  "siteUrl": "https://rideus.net",
  "depName": "서울", "arrName": "부산",
  "date": "20260810", "time": "0900",
  "trainNo": "101",
  "seatType": "standard",
  "passengers": 2,
  "paxBreakdown": { "adults": 1, "children": 1, "seniors": 0, "toddlers": 0 },
  "service": "korail",
  "live": true,

  "customerKey": "member-12345",
  "lastName": "홍", "firstName": "길동",
  "phoneCountry": "+82",
  "phone": "010-1234-5678",
  "email": "gildong@example.com",
  "country": "KR",
  "lang": "en"
}

결제수단 목록 #

POST/api/booking/methods

checkoutmethod 에 넣을 값의 출처입니다. 결제수단 이름은 저희 콘솔에 등록된 값과 글자까지 같아야 하므로, 받아 적지 말고 이 목록에서 고르게 하세요.

키에 맞는 것만 옵니다. 테스트 키에는 테스트 결제수단만, 운영 키에는 운영 결제수단만 내려갑니다 — 화면에 세운 뒤 고객이 고른 다음에야 막히는 일이 없게 하려는 것입니다.

names화면에 보여 줄 표기이고, checkout 에는 언제나 method 를 보냅니다.

이 엔드포인트는 RAILCONNECT 결제 모드 전용입니다. 파트너 결제 모드는 결제수단을 파트너 결제창에서 고르므로 호출할 일이 없습니다.

JSON
{}

통화 · 환율 #

요청에 lang 을 실으면 보여 줄 가격과 실제 청구 통화가 함께 갈립니다. 규칙은 하나입니다.

언제통화
langko 이거나 없음KRW — 원화 그대로
그 밖의 언어USD — 실시간 환율로 환산
결제수단이 해외카드USD언어와 무관합니다

해외카드는 한국어 화면이어도 달러입니다. 해외 카드사는 원화 청구를 받지 못하는 계약이 흔하고, 받더라도 고객에게 이중 환전이 붙습니다.

어느 응답에 오나

엔드포인트달러 값
availability열차마다 usd — 목록에 쓰는 값
reserveusd확정 금액(인원·등급 반영)
checkoutchargeAmount실제로 긁는 금액

환산액(usd)은 통화와 상관없이 항상 옵니다. 한국어 화면(원화 결제)에도 함께 내려 드립니다 — “45,000원 (약 $32.6)”처럼 병기하는 화면이 있는데, 그때 파트너가 자기 환율로 계산하면 저희가 긁는 금액과 어긋납니다.

⚠️ 무엇을 긁을지는 currency 가 정합니다. currency: "KRW" 면 카드에는 원화가 긁힙니다 — usd보여 주기 위한 값이지 청구액이 아닙니다. 실제 청구액은 언제나 checkoutchargeAmount 입니다.

lang 이 통화를 정합니다(해외카드는 checkoutmethod 도 봅니다). 환율은 결제 시점 값으로 고정되므로 단계 사이에 값이 조금 달라질 수 있습니다.

조회 응답

lang 이 한국어가 아니면 열차마다 usd 가 함께 옵니다. 원화 필드는 그대로 둡니다 — 정산의 기준이고 기존 코드가 이미 쓰고 있기 때문입니다.

JSON
{ "ok": true, "currency": "USD", "fxRate": 1380.0, "fxAt": 1755500000,
  "fxMarketRate": 1380.0, "fxSpread": 2.0,
  "trains": [ { "trainNo": "101",
    "generalFare": 59800, "ticketFee": 5980, "customerTotal": 65780,
    // 원화 값을 그대로 환산한 것. 짝이 같으므로 고르는 기준도 같습니다 —
    // 화면에 쓸 값은 customerTotal 입니다(generalFare 는 수수료가 빠져 있습니다)
    "usd": { "generalFare": 43.33, "firstSurcharge": 17.32,
             "ticketFee": 4.33, "customerTotal": 47.67 } } ] }

적용 환율

환산에 쓰는 값은 fxRate 입니다. 시장 환율(fxMarketRate)에 환전 여유분(fxSpread, %) 을 얹은 뒤의 값입니다 — 저희가 금액을 고시하는 순간과 실제로 정산되는 순간 사이의 환율 변동·해외결제 수수료를 흡수하기 위한 것입니다.

계산
fxRate = fxMarketRate ÷ (1 + fxSpread/100)
원화   = chargeAmount × fxRate          ← 정산 대조는 이 식으로

여유분은 저희 콘솔에서 정하며 변경 시 1분 안에 조회·결제에 반영됩니다. 여유분이 0이면 fxRatefxMarketRate 가 같습니다. 두 값을 모두 내려 드리는 이유는, 고객이 “왜 이 금액이냐”고 물었을 때 설명할 수 있어야 하기 때문입니다.

시장 환율은 하나은행 고시환율입니다(기본 매매기준율). 국내 은행·카드사 정산이 대개 그 값을 기준으로 해 대조하기 가장 쉽고, 장중에 움직입니다. 저희가 어느 종류를 쓰는지(매매기준율 · 현찰 살 때/팔 때 · 송금 보낼 때/받을 때)는 콘솔에서 정하며, 바꾸면 fxMarketRate 가 그 값으로 바뀝니다.

환율은 최대 1시간 캐시됩니다. 매 조회마다 바깥을 두드리면 그쪽이 막혔을 때 결제가 통째로 멈추기 때문입니다. 고시 시점과 결제 시점 사이에 값이 조금 달라질 수 있으니, 화면에 띄울 최종 금액은 checkout 응답의 chargeAmount 를 쓰세요.

결제

checkout 응답의 amount언제나 원화입니다(정산 기준). 고객 카드에 실제로 찍히는 값은 chargeAmount(currency 단위)이고, sdk.totalAmount 도 같은 값입니다.

환율을 확인할 수 없으면 달러로 열지 않습니다. 조회는 원화로 되돌리고 warning 을 실으며, checkoutCONFIG_MISSING 으로 거절합니다 — 오래된 환율로 긁으면 고객이 화면에서 본 금액과 다른 값이 카드에 찍힙니다.

환불은 결제한 금액 그대로 돌아갑니다. $32.20 를 받았으면 $32.20 를 돌려드립니다(환율이 그새 움직여도 동일). 왕복 한 편만 취소하면 원화 비율만큼 나눠 환불합니다.

환율은 결제 시점 값으로 고정됩니다. 조회와 결제 사이에 환율이 바뀌면 청구액이 조금 달라질 수 있으니, 결제 직전 checkout 응답의 chargeAmount 를 최종 금액으로 보여 주세요.

결제창 발급 #

POST/api/booking/checkout

RAILCONNECT 결제모드 전용. 선점(reserve)이 끝난 예약에 대해 고객이 결제할 주소를 발급합니다. 그 주소로 고객을 보내면 결제·검증·철도사 발권은 저희가 처리합니다. 파트너 결제모드는 이 엔드포인트를 쓰지 않고 pay 로 진행합니다.

요청 — 편도 필드

필드타입설명
rsvIdstring
필수
reserve 로 잡은 예약번호. 왕복이면 legs 로 대신 보냅니다.
legsobject[]
필수
왕복 — 가는 편·오는 편의 rsvId·fare. 한 결제에 담을 수 있는 구간은 2개까지입니다.
methodstring
필수
methods 가 준 값 그대로 (글자까지 같아야 합니다). methodName 으로 보내도 됩니다.
farenumber
필수
결제운임(철도사 청구액). 발권수수료는 저희가 더합니다.
langenum
선택
화면 언어 — 통화가 갈립니다.
orderRefstring
선택
파트너 주문식별자.

가는 편·오는 편을 legs 에 함께 담으면 결제창 하나로 묶입니다. 고객이 두 번 결제하지 않습니다. 각각 reserve 로 선점한 뒤 그 예약번호를 넣으세요.

발권수수료는 구간마다 계산해 합칩니다(합계에 한 번 매기면 반올림 때문에 구간별 기록과 어긋납니다). 위 예시라면 47,400+4,740 과 53,700+5,370 을 더해 111,210원이 청구됩니다. 한 결제에 담을 수 있는 구간은 2개까지입니다.

응답 필드

필드타입설명
payUrlstring① 고객을 여기로 보내면 끝입니다.
sdkobject② 직접 결제창을 열 때 쓰는 값 (아래 참고).
paymentIdstring우리 결제번호. 바꾸지 마세요.
amountnumber고객 청구액(원) = fare + 발권수수료. 언제나 원화입니다.
chargeAmount · currencynumber
string
카드에 실제로 찍히는 금액과 통화.
fxRatenumber적용 환율 (1 USD = 몇 원).
legsobject[]구간별 fare·ticketFee·customerTotal. 왕복이면 2건.
orderNamestring결제창·카드전표에 찍히는 이름.
envstring키 타입이 정합니다(test 키 → test 채널).
apiVersionstring채널이 쓰는 포트원 모듈 버전.
warningstring값이 있으면 payUrl 을 만들지 못한 것.

결제창을 여는 두 가지 방법

① payUrl
간단
고객을 payUrl 로 보냅니다. 저희 결제 화면이 열리면서 곧바로 포트원 결제창이 뜹니다. 포트원 SDK·채널·복귀 주소를 저희가 다 다룹니다.
② sdk
화면 안 바뀜
파트너 화면을 그대로 둔 채 그 위에 결제창만 띄웁니다. 응답의 sdk 값을 포트원 브라우저 SDK 에 그대로 넘기면 됩니다.

② 파트너 화면에서 바로 열기

sdk 안의 값은 손대지 말고 그대로 넘기세요. 금액·채널·웹훅 주소가 모두 들어 있습니다.

JSON
"sdk": {
  "apiVersion": "v1",              // v1 이면 아임포트 SDK, v2 면 포트원 SDK
  "storeId": "store-…", "impCode": "imp…", "tierCode": "…",
  "channelKey": "channel-key-…", "paymentId": "rd_…",
  "orderName": "(KTX) 편도 서울 → 부산 승차권",
  "totalAmount": 44440, "currency": "KRW", "payMethod": "CARD",
  "buyerName": "홍 길동",
  "redirectUrl": "https://…",      // 모바일 복귀 주소
  "noticeUrls": ["https://…/webhooks/portone"] }   // ⚠ 반드시 그대로 넘기세요
JSV2
<script src="https://cdn.portone.io/v2/browser-sdk.js"></script>

const { sdk } = await checkout(...);          // 위 응답
const r = await PortOne.requestPayment({
  storeId: sdk.storeId, channelKey: sdk.channelKey, paymentId: sdk.paymentId,
  orderName: sdk.orderName, totalAmount: sdk.totalAmount,
  currency: sdk.currency, payMethod: sdk.payMethod,
  customer: sdk.buyerName ? { fullName: sdk.buyerName } : undefined,
  redirectUrl: sdk.redirectUrl || undefined,
  noticeUrls: sdk.noticeUrls || undefined,
});
if (r && r.code) { /* 고객이 취소했거나 실패 */ }
else { /* 결제창은 닫혔습니다 — 확정은 웹훅/sync 로 확인하세요 */ }
JSV1
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>

if (sdk.tierCode) IMP.agency(sdk.impCode, sdk.tierCode);   // 하위상점 채널
else IMP.init(sdk.impCode);
IMP.request_pay({
  channelKey: sdk.channelKey, pay_method: "card",
  merchant_uid: sdk.paymentId,          // ⚠ 우리 결제번호 그대로
  name: sdk.orderName, amount: sdk.totalAmount, currency: sdk.currency,
  buyer_name: sdk.buyerName || undefined,
  m_redirect_url: sdk.redirectUrl || undefined,
  notice_url: sdk.noticeUrls || undefined,
}, function (rsp) { /* rsp.success 여부와 무관하게 확정은 웹훅으로 */ });

noticeUrls(V1 은 notice_url)를 빠뜨리지 마세요. 이 값이 없으면 결제는 성사되는데 저희에게 통지가 오지 않아 발권이 되지 않습니다. 실제로 이렇게 결제 4건의 통지를 잃은 적이 있습니다.

totalAmount·paymentId 를 바꾸지 마세요. 저희는 확정할 때 포트원에 다시 조회해 저희가 기록한 금액과 대조합니다. 어긋나면 발권하지 않습니다 — 고객은 결제만 되고 표를 못 받습니다.

둘을 섞어 쓰지 마세요. 한 paymentId한 번만 결제됩니다.

① payUrl 로 보내기

payUrl 로 보내면 페이지가 열리자마자 포트원 결제창이 바로 뜹니다. 금액·결제수단은 앞 화면에서 이미 고르고 왔으므로 확인 버튼을 한 번 더 누르게 하지 않습니다. 결제창을 닫거나 팝업이 막히면 금액·주문번호와 [결제하기] 버튼이 남아 다시 열 수 있습니다.

같은 창에서 열어도 되고(location.href = payUrl) 새 창으로 열어도 됩니다. 확인 화면을 한 번 거치게 하려면 주소 뒤에 ?auto=0 을 붙이세요. 모바일은 결제 뒤 복귀 주소로 돌아오며, 복귀 주소를 정하지 않았으면 이 결제 화면으로 돌아옵니다 — 그때는 결제창을 다시 열지 않습니다(이중 결제 방지).

주문자 정보

reserve 에 보내신 이름·이메일·전화번호가 그대로 결제창에 실립니다 — 포트원 전표·영수증과 PG 조회 화면에 남아, 결제 분쟁이 났을 때 대사할 근거가 됩니다.

안 보내신 항목은 빈칸으로 둡니다(저희가 지어내지 않습니다). 정확한 대사를 위해 셋 다 보내 주시길 권합니다.

결제 확정

결제 완료는 포트원 웹훅으로만 확정합니다. 웹훅은 “이 결제에 변화가 있다”는 신호로만 받고, 포트원에 직접 조회해 상태와 금액을 대조한 결과만 신뢰합니다. 그 뒤에야 철도사 결제·발권으로 넘어갑니다 — 결제창의 성공 응답은 근거로 쓰지 않습니다.

결제 완료는 포트원 웹훅으로만 확정합니다 — 결제창이 성공을 말해도 그것만으로는 처리하지 않습니다(브라우저 응답은 근거가 못 됩니다). 고객 화면이 중간에 닫혀도 결제는 정상 처리됩니다.

왕복 취소는 편별입니다. cancel 에 한 편의 rsvId 를 보내면 그 구간만 표를 물리고 그 구간 몫만 환불합니다. 나머지 편은 그대로 유효합니다.

발권이 한 편만 성공하면 자동으로 되돌리지 않습니다 — 고객은 결제했는데 한쪽 표만 있는 상태이므로 저희가 기록해 두고 사람이 판단합니다(콘솔에서 확인).

필요한 설정: API 키에 포트원 티어코드, 콘솔에 (환경·티어·결제수단) 채널. 하나라도 없으면 CONFIG_MISSING 과 함께 무엇이 비었는지 알려 드립니다. 이미 결제된 예약이면 ALREADY_PAID 입니다.

JSON
{
  "rsvId": "…",
  "method": "국내카드",
  "lang": "en",
  "fare": 40400,
  "orderRef": "주문식별자"
}

결제 · 발권 #

POST/api/booking/pay

서비스는 파트너 주문을 읽지 않습니다. 고객 결제가 끝난 예약만 authorizations에 담아 전달해야 결제됩니다(승인 없는 예약은 결제하지 않음).

요청 필드

필드타입설명
authorizationsobject[]
필수
고객 결제가 끝난 예약만 담습니다 — 승인 없는 예약은 결제하지 않습니다.
authorizations[]
.rsvId · .orderRef
string
필수
예약번호와 파트너 주문식별자.
authorizations[]
.paidAmount · .currency
number
string

필수
고객이 실제로 결제한 금액. 코레일 청구액이 이보다 크면 중단합니다.
authorizations[]
.payment
object
권장
고객이 실제로 결제한 수단. 파트너 PG 로 받은 건이면 꼭 보내 주세요 — 콘솔 '예매 목록'의 결제수단·승인번호가 이 값입니다(고객 카드전표와 대조).
liveboolean
쓰지 않음
더 보지 않습니다(2026-08-27부터). 실거래 여부는 콘솔의 라이브 게이트가 정합니다. 보내셔도 무시됩니다.
carrierenum
선택
지정 시 그 철도사만.
rsvIdstring
선택
지정 시 그 예약 하나만.

payment 를 보내지 않으면 우리가 철도사에 낼 때 쓴 카드가 결제수단으로 남습니다. 고객이 낸 수단과 다르므로, 파트너 PG 를 쓰신다면 반드시 함께 보내 주세요. 카드번호 전체는 보내지 마세요 — 마스킹된 표시용 문자열이면 충분합니다.

응답 필드

필드타입설명
results[].statusenumpaid · skipped · failed · paid-unrecorded · dry-run (아래 설명).
results[].codestring건너뛴 이유의 기계 판독용 코드(있을 때만). ALREADY_PAID · NOT_IN_CARRIER · UNVERIFIED 등.
results[].settledbooltrue더 시도할 것이 없는 확정 상태입니다 — 재시도하지 마세요.
results[].seatsobject[]인원 수만큼. 발권 직후 조회한 값이라 못 얻으면 빈 목록입니다.
results[].approvalNostring카드 승인번호. SRT 는 제공하지 않아 빈 값입니다.
results[].payLimitAtstringdry-run·방치홀드 결과에는 결제 기한이 함께 옵니다.
ticketFeeTotal
customerTotal
number금액이 확정된 건만 더한 값 — 건너뛴 건은 빠집니다.
holdSweepobject방치 홀드 자동취소 결과.
vouchersSentnumber우리가 보낸 승차권 바우처 수.
  • status: paid(결제·발권 완료) · skipped(승인없음/한도초과/이미결제 등, reason 참조) · failed · paid-unrecorded(결제는 됐으나 이력 기록 실패 — 사람이 확인해야 합니다) · dry-run.
  • ticketFeeTotal·customerTotal금액이 확정된 건만 더한 값입니다 — 건너뛴 건은 빠집니다.
  • settled: true 가 붙으면 재시도하지 마세요(2026-09-14 추가). 승인(authorizations)하신 예약이 철도사 미결제 목록에 없을 때 그 이유를 함께 돌려드립니다. code 로 갈라 보시면 됩니다.
    • ALREADY_PAID — 이미 결제·발권이 끝났습니다. approvalNo 가 함께 옵니다.
    • ALREADY_REFUNDED · ALREADY_CANCELLED — 이미 환불·취소된 예약입니다.
    • NOT_IN_CARRIER — 철도사 예약 목록에 없습니다(취소됐거나 구입기한이 지나 회수).
    • UNVERIFIED여기에만 settled 가 없습니다. 철도사 계정을 전부 확인하지 못해 판단을 미룬 것이니, 잠시 뒤 다시 시도해 주세요.
    그전에는 이런 예약이 응답에서 통째로 빠졌습니다 — 빈 results 가 "아직 안 됐다"와 "이미 끝났다"로 똑같이 보여, 끝난 주문을 계속 재시도하게 되는 원인이었습니다.
  • cardFallback(있을 때만) — 앞 카드가 거절되어 다음 카드로 결제된 경우, 거절된 카드와 사유가 담깁니다. 결제는 정상이지만 거절된 카드를 확인해 주세요(한도·유효기간). 저희 콘솔에서도 같은 값을 봅니다.
  • SRT는 카드 승인번호를 제공하지 않습니다approvalNo가 빈 값입니다(2026-08-10 실결제로 확인). 코레일은 승인번호로 카드사 대사가 되지만 SRT는 그 수단이 없어 예약번호(rsvId)와 금액으로 정산해야 합니다. 결제 성공 여부는 status: "paid" 로 판단하세요.

안전장치. 코레일 청구액이 paidAmount(고객 결제액)보다 크면 중단합니다. 건당 결제 상한을 넘으면 결제하지 않습니다. 같은 예약은 한 번만 결제됩니다(멱등).

결제 카드는 여러 장을 순서대로 씁니다. 저희가 철도사에 낼 때 쓰는 카드로, 파트너가 관리하실 것은 없습니다. 앞 카드가 거절되면(한도초과·유효기간) 다음 카드로 넘어가 결제를 이어 갑니다. 다만 통신 오류처럼 결제 여부를 알 수 없는 실패에는 넘어가지 않습니다 — 이미 승인된 건에 다시 긁으면 이중 결제가 되기 때문입니다. 그때는 failed 로 돌려드리니 sync 로 실제 상태를 확인한 뒤 재시도하세요.

JSON
{
  "live": true,
  "carrier": "korail",
  "rsvId": "…",
  "authorizations": [
    { "rsvId": "…", "orderRef": "주문식별자", "paidAmount": 119600, "currency": "KRW",
      "payment": { "method": "신한카드 5432-****-****-1234", "approvalNo": "16004215" } }
  ]
}

방치 홀드 자동취소 (코레일) #

결제창에서 이탈해 결제로 이어지지 못한 reserve 좌석을, 코레일 결제기한 전에 취소해 되돌립니다. pay 요청에 아래 필드를 추가하면 켜집니다(코레일 전용).

JSON
{
  "live": true,
  "authorizations": [ … ],
  "sweepStale": true,
  "heldRsvIds": ["…", "…"],
  "sweepStaleMinutes": 10
}

요청 필드

필드타입설명
sweepStaleboolean
필수
방치 홀드 자동취소 opt-in.
heldRsvIdsstring[]
필수
결제 진행 중이라 취소하면 안 되는 예약번호.
sweepStaleMinutesnumber
선택
방치 판정 나이, 기본 10분.

필수 — heldRsvIds. 우리 서비스는 파트너 주문 상태를 알지 못합니다. 지금 결제 진행 중인 예약번호를 반드시 heldRsvIds에 담아 보내야 합니다 — 빠뜨리면 결제창에 머무는 고객의 좌석이 취소될 수 있습니다.

authorizations에도 heldRsvIds에도 없고 sweepStaleMinutes를 넘긴 홀드만 취소됩니다. 실행 게이트는 sweepStale + live:true + 운영용 키(3중, 기본 OFF). 결과는 응답 results[]hold-cancelled / hold-kept / hold-dry-run으로 실립니다.

SRT 는 이 스윕을 돌리지 않습니다. sweepStale 을 보내도 SRT 예약은 대상에서 빠집니다 — 요청이 거부되지는 않고 그냥 지나갑니다.

대신 SRT 는 구입기한(약 10분)이 지나면 좌석이 자동으로 풀립니다. 방치된 좌석이 영영 묶이지는 않으니 별도 처리는 필요 없고, 다만 코레일보다 늦게 재판매된다는 차이만 있습니다. (코레일은 기한이 최대 20분이라 앞당길 이득이 커서 스윕을 둡니다.)

취소 · 환불 #

POST/api/booking/cancel

미결제 예약은 취소, 이미 결제·발권된 예약은 환불로 자동 분기합니다. 다인원 예약은 전 인원 티켓을 함께 환불합니다.

열차가 출발한 뒤에는 취소할 수 없습니다.(2026-09-07 변경 — 그 전에는 도착 기준이었습니다.) 철도사 규정상 출발한 열차의 반환은 역 창구 신청이라, 사이트에서 걸어 봐야 실패하거나 표만 물리고 환불이 어긋납니다. 출발 시각이 지난 예약에 cancel 을 부르면 stage: "policy" 로 거절합니다.

JSON
{ "ok": false, "stage": "policy",
  "error": "출발 시각이 지나 온라인으로는 취소할 수 없습니다. 역 창구에서 반환을 신청하셔야 합니다.",
  "depAt": "2026-09-15T05:13:00", "refundable": false }

고객에게는 역 창구를 안내해 주세요. 이 표는 저희 계정으로 예매돼 있어 고객이 철도사 앱에서 직접 반환할 수 없습니다 — 창구 외에는 길이 없습니다.

refund-quote 가 같은 판정을 미리 알려 줍니다(refundable: false · bandKey: "AFTER_DEPARTURE"). 취소 버튼을 그리기 전에 확인하시면 고객이 눌러 보고 거절당하는 일이 없습니다.

저희가 출발 시각을 모르는 예약(여정이 남지 않은 옛 건)은 막지 않습니다 — 모른다고 거절하면 반환할 수 있는 건까지 못 하게 됩니다.

RAILCONNECT 결제모드에서는 표를 물린 뒤 고객 환불까지 저희가 처리합니다(refund.customerRefund). 순서는 표 반환 → 고객 환불이고, 환불이 실패하면 기록해 두었다가 다시 시도합니다 — 조용히 넘어가면 고객은 표도 돈도 없습니다.

일부 티켓만 환불되고 실패하면 partialRefunded에 이미 환불된 목록이 실립니다(수동 확인 필요). 라이브 취소는 live:true + 운영용 키가 필요합니다.

이미 철도사에서 반환된 예약

파트너사가 철도사 사이트·앱에서 먼저 반환한 뒤 이 API 로 취소를 부르면, 물릴 표가 없습니다. 그때는 오류가 아니라 기록을 맞추고 고객 환불까지 처리한 결과가 옵니다.

JSON
{ "ok": true, "stage": "refunded", "rsvId": "…",
  "alreadyCancelledAtCarrier": true,        // 우리가 물린 게 아니라 이미 반환돼 있었다
  "refund": { "applied": { "carrierFee": null, … } } }

환불액 규칙은 평소 취소와 같습니다. 다만 철도사 위약금(carrierFee)은 null 입니다 — 그쪽 화면에서 반환돼 얼마를 뗐는지 저희가 알 수 없습니다.

철도사 계정을 하나라도 확인하지 못하면 이 처리를 하지 않습니다. 대신 RESERVATION_NOT_FOUND 가 돌아옵니다 — “로그인 실패”를 “표가 없다”로 읽으면 살아 있는 표를 환불하게 되기 때문입니다. 이 코드를 받으면 재시도하지 말고 콘솔에서 확인하세요.

JSON
{ "rsvId": "…", "service": "korail", "live": true }
// service 생략 시 trainGradeName 으로 코레일/SRT 판단. 힌트가 없으면 양쪽을 모두 확인.

환불 예상액 #

POST/api/booking/refund-quote

반환하기 전에 환불 예상액을 계산합니다. 철도사(코레일·SR)에 접속하지 않으므로 부작용이 없고, 예약이 실제로 있는지는 확인하지 않습니다 — 요청에 담긴 운임·출발시각을 그대로 씁니다.

요청 필드

필드타입설명
rsvIdstring
권장
결제 이력에서 운임·출발시각·발권수수료를 가져옴 (source:"payment").
farenumber
필수
결제운임(철도사 청구액). 정상운임·고객 결제액이 아님.
carrierenum
필수
korail | srt
depDate · depTimestring
필수
depAt(ISO 8601) 로 대신 보낼 수 있음.
arrDate · arrTimestring
선택
있으면 도착 후 반환 불가를 판정.
ticketFeePaidnumber
선택
생략하면 현재 요율로 추정(ticketFeeEstimated:true).

rsvId 하나만 보내는 것을 권장합니다. 우리가 결제한 예약이면 결제 때 남긴 운임·출발시각·발권수수료를 그대로 씁니다 — 그 사이 요율을 바꿔도 고객이 실제로 낸 금액으로 계산됩니다. 이미 반환된 건이면 예상액이 아니라 그때 적용한 실제 값alreadyRefunded:true 가 옵니다.

응답 필드

필드타입설명
refundableboolean반환할 수 있는가. 출발 시각이 지난 예약은 falsereason 만 옵니다.
chargednumber고객 청구액 = fare + ticketFeePaid.
cancelFee
cancelFeeRate · cancelFeeBase
number취소수수료 = charged 의 5%, 10원 올림. 요율을 매긴 기준은 charged 입니다.
refundAmountnumber고객 환불액 = chargedcancelFee.
customerLossnumber고객 총손실 = chargedrefundAmount (= 취소수수료).
carrierFee · carrierBandnumber
string
철도사 반환위약금(추정) — 환불액에서 빼지 않음.
cancelBandstring적용한 취소수수료 구간.
estimated
ticketFeeEstimated
boolean추정값인지.
sourcestringrsvId 로 물었고 결제 이력을 찾았을 때 "payment".

환불액 = 고객 청구액 − 취소수수료. 발권수수료는 청구액에 들어 있어 함께 돌아갑니다 — 고객이 실제로 잃는 돈은 취소수수료뿐입니다(customerLoss).

carrierFee 는 환불액에서 빼지 않습니다. 저희와 철도사 사이의 정산이며, 취소수수료보다 크면 그 차액은 파트너사가 감수합니다(정책). 참고용으로만 실어 보냅니다 — 고객 화면에 “위약금”으로 보여 주면 실제 환불액과 맞지 않습니다.

취소수수료는 고객 청구액 기준이며 10원 단위로 올림합니다. 코레일·SRT 공통입니다. 위 예시: 44,440 × 5% = 2,222 → 2,230.

철도사 반환위약금 구간

carrierBand 가 가리키는 구간입니다. 주말 요율은 출발일이 금·토·일 또는 공휴일일 때 적용됩니다(반환일이 아니라 출발일 기준입니다).

구분1개월~
출발 2일 전
출발
1일 전
출발 당일~
3시간 전
3시간 전~
출발 전
월~목요일무료5%
금~일 · 공휴일
명절(설·추석)
최저위약금
400원
5%10%20%

carrierBand·cancelBand오는 값은 아래 여섯 가지입니다. 위 표의 칸과 1:1 이 아닙니다 — 평일 앞쪽 세 칸은 요율이 모두 같아 한 구간으로 합쳐 옵니다.

bandKey오는 문자열요율
WD_BEFORE_3H평일 · 1개월~출발 3시간 전무료
WD_TO_DEP평일 · 출발 3시간 전~출발 직전5%
WE_BEFORE_2D주말 · 1개월~출발 2일 전까지400원
WE_BEFORE_1D주말 · 출발 1일 전5%
WE_SAMEDAY_3H주말 · 출발 당일~3시간 전10%
WE_TO_DEP주말 · 출발 3시간 전~출발 직전20%
AFTER_DEPARTURE출발 이후반환 불가

화면에 문자열을 그대로 쓰지 마시고 bandKey 로 분기하세요. 표시 문구는 규정 표기에 맞춰 바뀔 수 있지만 키는 바뀌지 않습니다.

출발 후 구간은 이 API 로 처리하지 않습니다. 철도사 규정에 출발 후 위약금(20분까지 15%·30%, 60분까지 40%, 도착까지 70%)이 있지만 역 창구 신청 전용입니다. 저희 cancel 은 출발 시각이 지나면 거절하니, 그때는 고객에게 역 창구를 안내해 주세요.

위 표는 철도사가 저희에게 매기는 위약금(carrierFee)입니다. 고객에게 청구하는 취소수수료(cancelFee)는 파트너사별로 따로 설정할 수 있고, 설정이 없으면 이 규정 요율을 그대로 씁니다.

전부 추정입니다. 철도사 위약금은 실제 반환 시점의 응답이 최종이며, 화면에는 “예상”으로 표시해야 합니다. 출발 시각이 지난 예약refundable:falsereason 만 오고 금액 필드는 null 입니다. 이미 반환된 건은 alreadyRefunded:true · estimated:false · refundedAt 과 함께 그때 적용한 실제 값이 옵니다.

JSON
{ "rsvId": "…" }

// 또는 직접 지정 — 우리가 결제하지 않은 예약
{
  "fare": 40400,
  "carrier": "korail",
  "depDate": "20260826",
  "depTime": "1000",
  "arrDate": "20260826",
  "arrTime": "1250",
  "ticketFeePaid": 4040
}

예매 내역 조회 #

POST/api/booking/bookings

회원의 예매 내역을 목록·상세로 돌려줍니다. 파트너 사이트의 “내 예매 내역” 화면에 그대로 쓰시면 됩니다.

예약번호는 철도사가 발급해 파트너 회원과 이어지지 않습니다. 그래서 reserve 때 보낸 값으로 찾습니다 — 회원이면 customerKey, 비회원이면 예약자 정보(이메일·전화번호)입니다.

이름을 다른 언어로 보여 주실 때 — 저희는 철도사가 준 한국어 원문을 그대로 싣습니다. 옮겨 쓰실 값은 이렇게 얻으세요.

무엇어디서
역 이름stations 응답의 names — 역마다 13개 언어가 딸려 옵니다. 표기의 정본이라 로컬 표를 만들지 마세요 (역이 늘거나 표기가 바뀌면 뒤처집니다).
열차 이름응답에 영문이 함께 옵니다trainTypeNameEn · trainEn(availabilitytrainTypeNameEn). 국문·영문 두 가지뿐이고, 그 밖의 언어 화면에서도 영문을 쓰시면 됩니다.

저희가 모르는 등급이면 영문 키가 비어서 옵니다 — 지어내지 않습니다. 그때는 한국어 원문을 쓰세요. 새 등급이 생기면 알려 주시면 넣겠습니다.

회원 목록

JSON
{ "customerKey": "member-12345",
  "status": "paid",        // (선택) reserved | cancelled | expired | paid | refunded | failed
  "includeUnpaid": false,  // (선택) 결제 전 선점까지 볼지 — 기본 false
  "limit": 50 }            // (선택) 기본 50, 최대 200

결제되지 않은 좌석 선점은 기본으로 빠집니다. reserve 만 하고 결제창에서 이탈한 건인데, 목록에 섞이면 고객이 “예매됐다”고 오해합니다 — 그 좌석은 철도사 결제기한이 지나면 그냥 사라집니다. 사라진 것은 status: "expired" 로 내려가므로, 결제 대기로 세지 마세요(예약 세션 상태).

선점까지 보려면 includeUnpaid: true 또는 status: "reserved" 를 쓰세요. 응답의 includeUnpaid 로 무엇을 기준으로 받은 목록인지 알 수 있습니다.

결제가 끝났으면 발권 전이어도 나옵니다. 발권은 결제 통지를 받은 뒤에 돌기 때문에, 그 사이에 고객이 빈 화면을 보지 않게 한 것입니다.

인원별 운임

어른·어린이가 각각 얼마를 냈는지paxFares 로 옵니다. 좌석마다 결제운임·할인율·발권수수료·소계가 붙어, 고객에게 보여 줄 표를 그대로 그릴 수 있습니다.

JSON
"paxFares": {
  "exact": true, "source": "carrier",
  "rows": [
    { "label": "어른1", "type": "adults", "seat": "5호차 6A",
      "netPay": 7500,        // 결제운임 (철도사 운임)
      "discountRate": null,  // 할인율 — 할인이 걸린 좌석에만
      "fee": 750,            // 발권수수료
      "subtotal": 8250 },    // 소계 = netPay + fee
    { "label": "어른2", "type": "adults", "seat": "5호차 6B",
      "netPay": 7500, "discountRate": null, "fee": 750, "subtotal": 8250 } ],
  "totals": { "netPay": 15000, "fee": 1500, "subtotal": 16500 } }

청구·정산 기준은 여전히 customerTotal 입니다. paxFares보여 주기 위한 내역이라, 인원별 소계를 더한 값이 총액과 몇십 원 다를 수 있습니다 — 발권수수료를 10원 단위로 올리기 때문에 인원 수만큼 올림이 생깁니다.

exact: false 면 표를 그리지 마세요. 합계를 되짚지 못한 것이라 rows 가 비어 있습니다(요율이 바뀌었거나 좌석등급이 섞인 경우). 없는 표를 지어내 보여 주는 것보다 안 보여 주는 편이 낫습니다.

sourcecarrier철도사가 준 인원별 금액입니다 — 가장 정확합니다. 합계에서 비율로 나눈 값이 아닙니다(할인이 인원 종류마다 다르게 붙어 나누면 둘 다 틀립니다).

승차권 QR

발권된 코레일 승차권은 상세 응답에 QR 이미지 주소가 함께 옵니다. 파트너가 직접 바우처를 보내실 때 <img> 로 그대로 걸면 됩니다 — QR 라이브러리를 붙이실 필요가 없습니다.

JSON
{ "booking": {
    "seats": [
      { "carNo": "10", "seatNo": "11A",
        "qrImageUrl": "https://…/qr/{파트너}/{예약번호}/0/{서명}.png" },
      { "carNo": "10", "seatNo": "11B",
        "qrImageUrl": "https://…/qr/{파트너}/{예약번호}/1/{서명}.png" }
    ],
    "seatClass": "standard",          // standard(일반실) | first(특실) · 모르면 없음
    "qrImageUrl": "https://…/qr/{파트너}/{예약번호}/{서명}.png" } }

고객에게 보낼 때 아래 문구를 함께 넣어 주세요. 이 QR 은 시간마다 바뀌지 않는 고정 코드라, 캡처 한 장으로 여러 명이 보여 줄 수 있습니다.

“승차시 탑승권을 소지해야 하며, 사진이나 캡처한 화면은 유효한 승차권이 아닙니다.”

QR 은 승객마다 다릅니다. 코레일은 인원별로 승차권을 따로 끊고 각각 다른 QR 을 줍니다 — 2인 예약이면 QR 도 두 장입니다. seats[].qrImageUrl 을 좌석마다 그려 주세요. 한 장만 보내면 두 번째 승객은 개찰구에서 보여 줄 표가 없습니다.

최상위 qrImageUrl첫 좌석의 것입니다. 기존 연동이 그 키를 읽고 있어 그대로 두었지만, 새로 만드시는 화면은 seats[] 를 쓰세요. 2026-08-24 이전에 발권된 예약은 좌석에 QR 이 없어 seats[].qrImageUrl 이 오지 않습니다 — 그때는 최상위 한 장으로 떨어집니다.

SRT 는 QR 을 주지 않습니다 — 그 예약에는 qrImageUrl 이 아예 없습니다. 값이 있을 때만 그리세요.

주소는 서명이 붙어 있어 예약번호만으로는 열리지 않습니다. 그대로 쓰시고 만들어 쓰지 마세요. 취소·환불된 승차권은 더 이상 열리지 않습니다(404) — 물린 표의 QR 이 유효해 보이면 안 되기 때문입니다.

비회원 목록

비회원 예매를 받는 파트너는 customerKey 가 없을 수 있습니다. 그때는 예약자 정보로 찾습니다.

JSON
{ "email": "gildong@example.com" }                 // 이메일로
{ "phone": "010-1234-5678" }                       // 전화번호로
{ "email": "…", "lastName": "홍", "firstName": "길동" }  // 이름으로 더 좁히기

이름만으로는 조회할 수 없습니다. 동명이인이 남의 예매를 보게 되기 때문입니다 — email 이나 phone 이 반드시 있어야 하고, 이름은 그 위에 얹는 조건으로만 쓰입니다.

전화번호는 표기가 제각각이라(010-1234-5678 / +82 10-1234-5678) 숫자만 남겨 뒤 8자리로 맞춥니다.

둘을 함께 보내면 둘 다 맞아야 합니다. 다만 숫자가 8자리에 못 미치는 전화번호는 거절합니다(INVALID_REQUEST) — 국가번호만 보내거나 자리가 모자란 값을 조용히 무시하면, 파트너는 두 조건으로 좁힌 줄 알지만 실제로는 이메일만으로 조회되어 번호가 틀린 사람에게 남의 예매가 보입니다.

응답의 matchedBy 는 무엇으로 찾았는지(customerKey / booker), matchedFields실제로 걸린 조건(["email","phone"] 등)을 알려 줍니다 — 보낸 값이 쓰였는지 응답만 보고 확인할 수 있습니다.

JSON
{ "ok": true, "stage": "listed", "count": 2, "truncated": false,
  "customerKey": "member-12345",
  "matchedBy": "customerKey",        // customerKey | booker (이메일·전화로 찾음)
  "includeUnpaid": false,            // 결제 전 선점을 포함해 받은 목록인지
  "bookings": [
    { "rsvId": "…", "orderRef": "…", "status": "paid",
      "service": "코레일", "train": "KTX 101",
      "depName": "서울", "arrName": "부산",
      "depDate": "20260910", "depTime": "090000", "arrTime": "094500",
      "passengers": 2, "paxBreakdown": { "adults": 2 },
      "fare": 40400, "ticketFee": 4040, "customerTotal": 44440,
      "refundAmount": null, "approvalNo": "…",
      "paidAt": "…", "refundedAt": null, "createdAt": "…" }
  ] }

statusreserved(선점만 됨) · cancelled(결제 전에 취소) · expired(결제기한 경과로 좌석 회수) · paid(발권) · refunded(결제 뒤 취소·환불) · failed(결제 실패)입니다. cancelledrefunded 는 다릅니다 — 앞은 돈이 오가기 전에 접은 것이고, 뒤는 결제한 뒤 되돌려준 것입니다. truncated:truelimit 에 걸려 잘린 것이니 더 크게 요청하세요(최대 200).

service 는 표시용 한글입니다"코레일" / "SRT". reserve·cancel 이 받는 "korail"/"srt" 와 다르므로 이 값으로 분기하지 마세요. 그대로 화면에 찍는 용도입니다.

한 건 상세

JSON
{ "rsvId": "…" }
JSON
{ "ok": true, "stage": "found",
  "booking": {
    "rsvId": "…", "status": "refunded", "train": "KTX 101",
    "depName": "서울", "arrName": "부산", "depAt": "…",
    // 인원 수만큼 옵니다 — 2인 예약이면 2개. price 는 **철도사 운임**이라
    // 발권수수료가 빠져 있습니다(수수료까지 더한 값은 paxFares 의 subtotal).
    // price·paxType·discountName 은
    // 철도사가 인원별로 준 값이라, 못 얻었으면 그 키가 빠집니다.
    "seats": [ { "carNo": "3", "seatNo": "5A", "price": 22400, "paxType": "어른" },
               { "carNo": "3", "seatNo": "5B", "price": 11200, "paxType": "어린이",
                 "discountName": "다자녀행복" } ],
    "booker": { "customerKey": "member-12345", "lastName": "홍", "firstName": "길동",
                "email": "…", "phone": "…", "phoneCountry": "+82", "country": "KR" },
    "fareBreakdown": { "listFare": 53900, "discount": 13500, "discountRate": 25.0,
                       "fare": 40400, "ticketFee": 4040, "ticketFeeRate": 10.0,
                       "customerTotal": 44440 },
    "payMethod": "신한카드 5432-****-****-1234", "payApprovalNo": "16004215",
    "refund": { "carrierFee": 2020, "cancelFee": 2020, "cancelFeeRate": 5.0,
                "cancelBand": "평일 · 출발 3시간 전~출발 직전",
                "refundAmount": 42210, "refundedAt": "…",
                "ticketFeeRefunded": true } } }   // 청구액에 포함돼 함께 환불됨

환불액 = 고객 청구액 − 취소수수료 입니다. 발권수수료는 청구액에 들어 있어 함께 돌아가므로, 고객이 실제로 잃는 돈은 취소수수료뿐입니다(customerLoss). 취소수수료는 고객 청구액 기준이며 10원 단위로 올림합니다. 철도사 위약금은 환불액에서 빼지 않습니다 — 저희와 철도사 사이의 정산이고, 취소수수료보다 크면 그 차액은 파트너사가 감수합니다.

refund 는 환불된 건에만 옵니다. seats 는 발권 시점에 조회한 값이라 못 얻었으면 빈 목록입니다.

저희 기록을 읽습니다. API 를 거치지 않은 변화 — 예를 들어 파트너사가 철도사 사이트·앱에서 직접 취소한 경우 — 는 여기 반영되지 않습니다. 지금 철도사 상태까지 확인해야 한다면 sync 를 쓰세요.

조회 범위는 인증한 파트너 자신입니다. 다른 파트너의 예약은 같은 customerKey 를 쓰더라도 보이지 않습니다.

실시간 상태 대사 #

POST/api/booking/sync

예약 조회는 이 엔드포인트로 합니다. 예약번호를 넣으면 지금 상태(유효 / 발권됨 / 취소·만료)를 철도사 실시간 목록과 대사해 돌려줍니다. 저희 DB 를 읽는 게 아니라 코레일·SR 에 직접 물어보므로, API 를 거치지 않은 변화(파트너사가 철도사 사이트·앱에서 직접 취소한 경우 등)도 잡힙니다.

회원 단위 조회는 bookings 를 쓰세요. 이 엔드포인트는 rsvIds 로만 조회합니다.

둘의 차이: bookings저희 기록(빠르고 상세)이고, sync철도사에 직접 물어 지금 상태를 확인합니다(파트너사가 철도사 사이트·앱에서 직접 취소한 경우까지 잡힙니다).

한 건만 볼 때도 rsvIds 에 하나만 담으면 됩니다. 발권된 건은 호차·좌석번호가 함께 옵니다. 승차권에는 예약번호가 없어(철도사가 주지 않습니다) 열차·일자·시각으로 맞춰야 하는데, 그 정보는 선점 때 저장해 둔 여정으로 저희가 채웁니다matchers 를 보내지 않아도 발권건을 인식합니다. 열차를 변경하는 등 저희 기록보다 최신 정보가 있을 때만 matchers 로 덮어쓰세요.

요청 필드

필드타입설명
rsvIdsstring[]
필수
조회할 예약번호. 한 건만 볼 때도 하나만 담으면 됩니다.
matchersobject[]
선택
발권 매칭 정확도↑. 열차를 변경하는 등 저희 기록보다 최신 정보가 있을 때만 덮어쓰세요.

응답 필드

필드타입설명
verifiedAllboolean전 계정을 확인했는가 — false 면 응답에 없는 예약번호를 “취소됨”이 아니라 “미확정”으로 다뤄야 합니다.
activestring[]아직 유효(미결제 포함).
ticketedobject[]발권됨. 인원 전체는 seats[] 를 쓰세요carNo·seatNo첫 좌석 하나입니다.
ticketed[].seatNoEndstring철도사가 채워 주지 않습니다 — seats 를 쓰세요.
cancelledstring[]취소·만료.
totalActive
totalTickets
number보내신 rsvIds 해당하는 건수입니다(= active·ticketed 배열의 길이). 계정에 있는 다른 예약은 세지 않습니다.
verifiedAll 을 반드시 보세요. 예약은 그것을 잡은 계정에서만 보이므로 서비스는 등록된 계정을 모두 확인합니다. 일부 계정이 로그인·조회에 실패하면 verifiedAll: false 로 내려가고, 그때 사라진 예약번호를 cancelled 에 넣지 않습니다 — 못 본 계정이 들고 있을 수 있기 때문입니다. 이 경우 응답에 없는 예약번호는 "취소됨"이 아니라 "미확정" 으로 다뤄야 합니다. 취소로 단정하면 살아 있는 고객 예약을 잃습니다.

이 칸은 ok: trueok: false 든 항상 옵니다(2026-09-14부터). 칸이 없으면 오류로 다뤄 주세요 — 참으로도 거짓으로도 읽지 마십시오. 그전에는 빠지는 경로가 하나 있었는데, 그것이 아래 rsvIds 를 안 실은 경우였습니다.

예약번호는 rsvIds 배열로 보내세요. rsvId(단수)나 rsv_ids 가 아닙니다.

필드 이름이 다르면 저희는 아무것도 묻지 않은 것으로 읽습니다. 2026-09-14부터 그런 요청은 ok: false · code: "NO_RSV_IDS" 로 거절합니다. 그전에는 ok: true 와 빈 배열이 나가서, 받는 쪽에서는 "확인했는데 없다"와 구분할 수 없었습니다 — 발권된 표가 한나절 "확인되지 않음"으로 남은 사고가 있었습니다.

JSON
{
  "rsvIds": ["…", "…"],
  "matchers": [
    { "rsvId": "…", "service": "korail", "trainNo": "101",
      "depDate": "20260810", "depTime": "0900" }
  ]
}

수수료 · 고객 청구액 #

콘솔(API 키 상세 > 수수료)에 등록한 발권 수수료율이 조회·선점·결제 응답에 함께 실립니다. 요율을 따로 확인해 곱하지 마세요 — 응답 값을 그대로 쓰면 됩니다. 코레일·SRT 공통 요율입니다.

JSON
// availability — 열차마다. 1인 일반실(generalFare) 기준이라 확정 금액이 아님
{ "ticketFeeRate": 10.0, "feeBasis": "1인 일반실 기준 — 확정 금액은 reserve 응답을 쓸 것",
  "trains": [ { "generalFare": 47400, "ticketFee": 4740, "customerTotal": 52140 } ] }

// reserve — 예약 총액(reservation.price) 기준. **고객에게 청구할 확정 금액**
{ "ok": true, "reservation": { "price": 40400 },
  "ticketFeeRate": 10.0, "ticketFee": 4040, "customerTotal": 44440 }

// pay — 건별 + 합계. 금액이 확정된 건(paid·dry-run)만 계산에 넣음
{ "results": [ { "amount": 40400, "ticketFee": 4040, "customerTotal": 44440 } ],
  "ticketFeeTotal": 4040, "customerTotal": 44440 }

결제·반환 시점의 수수료는 그때 값 그대로 저장됩니다. 나중에 요율을 바꿔도 지난 내역이 흔들리지 않습니다 — pay 는 발권수수료·요율·고객 청구액을, cancel 은 적용한 취소수수료·구간·철도사 위약금·환불액을 남깁니다.

JSON
// cancel 응답의 refund.applied — 반환 시점에 실제로 적용한 값
{ "refund": { "amount": 38380, "fee": 2020,          // 철도사 반환액·위약금(실제값)
              "applied": { "fare": 40400,
                           "charged": 44440,          // 고객 청구액
                           "carrierFee": 2020,        // 환불액에서 빼지 않음
                           "cancelFee": 2230, "cancelFeeRate": 5.0,
                           "cancelFeeBase": 44440,    // 요율 기준 = 청구액
                           "cancelBand": "평일 · 출발 3시간 전~출발 직전",
                           "refundAmount": 42210,     // = charged − cancelFee
                           "ticketFeePaid": 4040,     // 청구액에 포함돼 함께 돌아감
                           // 요율을 어디서 가져왔나: partner(키 설정) | default(규정) | none
                           "rateSource": "partner" } } }

rateSource: "none" 이면 출발시각을 몰라 구간을 정하지 못해 취소수수료를 0원으로 처리한 것입니다(옛 결제분). 그때는 applied.note 에 그 사실이 함께 옵니다 — 금액이 이상해 보이면 이 두 값을 먼저 보세요.

정상운임·운임할인은 조회·선점 시점의 예약 안내 문구에서 되짚습니다. 특실은 일반실 운임 + 특실 추가요금이고 할인은 일반실 운임에만 걸립니다 — 예: 정가 59,800 + 추가 23,900 = 83,700, 25% 할인이면 결제 68,700(할인 15,000).

발권수수료는 결제운임 기준으로 계산해(10원 단위 올림) 운임에 가산해 청구합니다 — 고객이 내는 돈은 결제운임 + 발권수수료(customerTotal)입니다. 반환할 때는 고객 청구액 전체가 환불 대상이고 거기서 취소수수료만 뺍니다 — 즉 발권수수료도 함께 돌아갑니다. 취소수수료는 고객 청구액 기준(10원 올림)이고 출발까지 남은 시간에 따라 구간별로 갈리며 refund-quote 로 미리 확인할 수 있습니다. 요율이 0이어도 필드는 항상 옵니다(존재 여부로 분기하지 마세요).

결제 모드 · 콜백 · 웹훅 #

API 키마다 결제 모드를 정합니다(콘솔 › API 키 상세). 흐름이 완전히 다릅니다.

Partner파트너가 고객 결제를 받습니다. 결제가 끝난 예약을 payauthorizations 에 담아 보내면 저희가 철도사에 결제·발권합니다. 고객이 실제로 낸 수단은 payment 로 함께 보내 주세요.
RAILCONNECT저희가 고객 결제를 받습니다. methods 로 결제수단을 고르고 checkout 으로 결제 주소를 받아 고객을 보내면, 결제·확정·철도사 발권까지 저희가 처리합니다. pay 를 호출하지 마세요.

2026-09-07 — RIDEUS 모드의 이름이 RAILCONNECT 로 바뀌었습니다. 같은 모드이고 동작은 하나도 달라지지 않았습니다.

고치실 것은 없습니다. 이 값은 저희 내부에서 키 설정을 가리키는 이름이라 API 응답에 실리지 않습니다 — 파트너 코드가 이 문자열을 다룰 일이 없습니다. 콘솔 화면과 이 문서의 표기만 바뀝니다.

RAILCONNECT 모드 흐름

FLOW
methods            결제수단 목록 (파트너) — checkout 에 넣을 이름의 출처
  → reserve        좌석 선점 (파트너)
  → checkout       결제 주소 발급 (파트너) — payUrl 을 고객에게
  → 고객 결제      payUrl 로 보내면 포트원 결제창이 바로 뜹니다
  → 포트원 웹훅    저희가 포트원에 재조회 + 금액 대조 후 **여기서 결제 확정**
  → 철도사 발권    저희가 처리(뒤에서 실행) → ticket.issued 웹훅
  → redirect_url   고객 브라우저를 파트너 사이트로 복귀

cancel             표 반환 + 고객 환불(포트원)까지 저희가 처리

결제 완료를 고객 브라우저의 복귀만으로 판단하지 마세요. 확정은 웹훅에서 이뤄지므로, 파트너 화면은 ticket.issued/payment.paid 웹훅이나 sync 로 확인해야 정확합니다. 고객이 창을 닫아도 결제·발권은 정상 진행됩니다.

항목설명
포트원 티어코드(RAILCONNECT) 결제수단별 채널을 고르는 값. 없으면 결제창을 열 수 없습니다(CONFIG_MISSING).
웹훅 URL아래 이벤트 발생 시 서버-투-서버 통지를 받을 주소. 받을 이벤트를 고르지 않으면 전부 보냅니다. 재시도는 없습니다(5초 타임아웃) — 정확한 상태는 sync·bookings 로 확인하세요.
예약완료 리다이렉트(RAILCONNECT) 결제 후 고객 브라우저를 되돌릴 주소. ?paymentId=… 가 붙습니다. 비우면 결제 페이지에 머무릅니다 — 모바일에서는 고객이 돌아갈 곳을 잃습니다.
허용 오리진(CORS)브라우저에서 직접 호출할 때 허용할 오리진 목록. 서버 간 호출이면 비워 두세요. 브라우저에 두는 키는 API Key·Secret Key 가 고객에게 노출되므로 개발용 키만 쓰세요.

오류 코드

실패 응답에는 code 가 실립니다. 분기는 이 값으로 하세요stage 는 내부 진행 단계이고 error 는 사람이 읽는 설명이라 문구가 바뀔 수 있습니다. 코드의 의미는 바뀌지 않으며, 새 상황이 생기면 새 코드가 추가됩니다.

JSON
{ "ok": false, "code": "SOLD_OUT", "stage": "reserve",
  "error": "매진되었습니다" }
코드같은 요청 재시도의미
인증 · 권한
AUTH_REQUIRED고쳐야 함API 키가 없다
AUTH_INVALID고쳐야 함유효하지 않거나 비활성인 키
ORIGIN_NOT_ALLOWED고쳐야 함이 키가 허용하지 않은 오리진
LIVE_GATE_CLOSED고쳐야 함라이브 게이트가 꺼져 있어 실제 처리를 하지 않음
요청
INVALID_REQUEST고쳐야 함필수 값 누락·형식 오류
SRT_DISCONTINUED고쳐야 함SRT 예약은 받지 않습니다 — 2026-09-01 통합운행으로 KTX 로 통합됐습니다. 같은 구간을 KTX 로 예약하세요.
TRAIN_NOT_FOUND고쳐야 함그 날짜·구간에 그 열차번호가 없습니다(하루 운행 전체를 확인한 결과입니다). 조회 응답의 trainNo 를 그대로 보내셨는지 확인해 주세요. 응답의 candidates 에 실제 운행 열차가 함께 옵니다.
SOLD_OUT고쳐야 함매진
RESERVATION_NOT_FOUND고쳐야 함해당 예약을 찾지 못함
NOT_AUTHORIZED고쳐야 함authorizations 에 없는 예약 — 결제 대상 아님
설정
NO_ACCOUNT고쳐야 함이 키에 철도사 계정이 없음
NO_CARD고쳐야 함이 키에 결제 카드가 없음
CONFIG_MISSING고쳐야 함건당 상한 등 필수 설정 누락
철도사
CARRIER_LOGIN_FAILED가능철도사 로그인 실패
CARRIER_BLOCKED불가저희가 철도사에 붙지 못하고 있습니다. 재시도해도 같으니 폴러가 반복 호출하지 않도록 해 주세요. error 문구는 고객에게 그대로 보여 주셔도 됩니다 — 내부 사정은 담지 않았습니다. 저희에게 알려 주시면 조치합니다.
CARRIER_UNAVAILABLE가능철도사가 응답하지 않거나 전 계정 실패
SEARCH_FAILED가능열차 조회 실패
DATE_NOT_OPEN고쳐야 함그 날짜는 아직 예매가 열리지 않았습니다
DATE_SPECIAL_PERIOD고쳐야 함명절 특별수송기간 — 예매일이 따로 지정됩니다. 안내문이 error 에 그대로 옵니다
RESERVE_FAILED가능좌석 선점 실패
결제 · 금액
AMOUNT_EXCEEDED고쳐야 함건당 상한 초과
AMOUNT_MISMATCH고쳐야 함철도사 청구액이 고객 결제액보다 큼
ALREADY_PAID고쳐야 함이미 결제된 예약
PAY_FAILED가능카드 승인 실패. 등록된 카드가 모두 거절된 경우도 여기입니다 — 재시도해도 같으니 저희에게 알려 주세요.
PAY_UNRECORDED고쳐야 함결제는 됐으나 이력 기록 실패 — 사람이 확인해야 함
환불 · 기타
REFUND_FAILED가능환불 실패
INTERNAL가능서버 내부 오류

가능은 같은 요청을 그대로 다시 보내도 되는 경우입니다(철도사 일시 장애 등). 고쳐야 함은 요청이나 설정을 바꿔야 하므로 재시도해도 같은 결과입니다.

pay 는 예약별로 결과가 갈립니다. 응답 전체가 ok: true 여도 개별 건은 실패일 수 있습니다 — results[].code 를 확인하세요(정상 결제 건에는 code 가 없습니다).

진행 단계(stage)

분기는 code 로 하세요. stage어디까지 갔다가 멈췄는지를 알려 주는 값입니다 — 같은 INTERNAL 이어도 철도사 로그인에서 막힌 것과 좌석을 잡다 막힌 것은 대응이 다릅니다. 문의하실 때 이 값을 함께 주시면 저희가 로그를 바로 찾습니다.

stage누구 쪽 문제무슨 뜻인가
여기까지 왔으면 성공
reserved좌석 선점 완료(reserve)
checkout결제창을 열 준비 완료(checkout)
cancelled · refunded취소·환불 완료(cancel)
already-refunded이미 취소·환불이 끝난 예약입니다(cancel). alreadyCancelled: truecode: "ALREADY_REFUNDED" 가 함께 옵니다 — 이번 호출로 새로 처리한 것은 없으니 재시도하지 마시고, 고객 환불도 다시 걸지 마세요. refund 에 처음 처리된 환불 내역이 실립니다.
listed · found목록·단건 조회 완료(bookings)
dry-run실거래가 아닌 시험 호출. 좌석은 잡히지 않았습니다.
요청을 고쳐야 하는 것 — 다시 보내도 같습니다
input파트너필수 값이 없거나 형식이 어긋남
match파트너지정한 열차·예약을 목록에서 찾지 못함. 조회 결과가 오래됐을 때 납니다availability 부터 다시 부르세요.
duplicate파트너이미 결제된 예약으로 다시 결제창을 열려 함
policy파트너규정상 할 수 없음(예: 열차 도착 후 취소)
설정 문제 — 저희에게 알려 주세요
gate설정라이브 게이트가 꺼져 있어 실제 처리를 하지 않음
creds · env설정이 키에 철도사 계정이 등록돼 있지 않음
config설정포트원 채널·티어 설정이 비어 있음
철도사 쪽 — 잠시 뒤 같은 요청을 다시 보내도 됩니다
login · init철도사철도사 로그인 실패
search철도사열차 조회 실패
reserve철도사좌석 선점 실패(매진 포함)
list · cancel · refund철도사예약 조회·취소·반환 실패
all-accounts-failed철도사등록된 계정을 전부 시도했으나 모두 실패
저희 쪽 — 알려 주시면 고칩니다
import저희철도사 연동 모듈을 못 불러옴
crash · exception · unknown저희예상 못 한 오류

실패했을 때 함께 오는 값

예약이 여러 계정을 돌며 시도되기 때문에, 실패 응답에는 그 경과가 함께 옵니다. 화면에 그대로 띄우지 마세요 — 고객이 읽을 말이 아니라 문의용 자료입니다.

필드어디무엇
lastStage · lastErrorreserve 마지막 시도가 어디서 왜 멈췄는지. stageall-accounts-failed 일 때 실제 사유는 여기 있습니다.
attemptsreserve 계정별 시도 기록 — accountIndex(몇 번째 계정) · stage · error. 계정 아이디는 담지 않습니다.
priorAttemptsreserve 성공 응답에 붙습니다. 앞선 계정이 실패한 뒤 다음 계정으로 성공했다는 뜻이며, 예약은 정상입니다.
liveAllowedcancel false 면 라이브 게이트가 꺼져 있어 철도사에 취소 요청이 가지 않았습니다. 취소된 것으로 처리하지 마세요.
cardsTriedpay 등록된 카드를 몇 장까지 시도했는지(앞 카드가 거절될 때만 다음 장으로 넘어갑니다).

철도사 계정 아이디는 응답에 담지 않습니다. 예약·결제는 등록된 철도사 계정으로 이뤄지지만, 그 로그인 아이디가 파트너 서버 로그나 브라우저에 남을 이유가 없습니다. 계정을 가릴 때는 accountIndex(몇 번째 계정)만 드립니다 — 어느 계정을 손봐야 하는지는 저희 콘솔에서 봅니다.

웹훅 이벤트

이벤트발송 시점data
booking.created예약 선점 성공 rsvId · service · train · reservation
payment.paid결제 승인 rsvId · orderRef · amount · approvalNo
ticket.issued결제와 동시 발권 시 payment.paid 와 함께 payment.paid 의 값 + seats(호차·좌석) + qrImageUrl(코레일 승차권 QR 이미지 주소)
바우처를 직접 만드실 수 있게 담았습니다 — 이 통지만으로 메일을 보낼 수 있습니다. 값이 없으면(SRT·좌석 미조회) 그 키는 아예 오지 않습니다.
booking.cancelled미결제 예약 취소 rsvId · service
refund.completed발권 티켓 환불 rsvId · refund

구독 이벤트를 하나도 고르지 않으면 전체를 보냅니다. 실제 거래(live)만 통지하며 dry-run 은 보내지 않습니다. 왕복은 구간마다 따로 발송됩니다(예약번호가 다르므로 2통).

파트너가 부르지 않아도 오는 통지가 있습니다. 다음 두 경우에도 refund.completed 가 발송됩니다 — 받은 쪽에서 “내가 요청한 적 없는데”라고 버리면 고객은 환불받았는데 파트너 주문은 유효한 채로 남습니다.

  • 운영자가 저희 콘솔에서 취소한 경우.
  • 파트너사가 철도사 사이트·앱에서 직접 반환한 경우 — 저희가 주기 점검(30분)으로 찾아내 발권취소로 넘기고 고객 환불까지 처리합니다.

같은 이유로 웹훅은 멱등하게 처리하세요. 이미 처리한 rsvId 의 같은 이벤트가 다시 오면 무시하면 됩니다.

웹훅 페이로드

TEXT
POST <웹훅 URL>
Content-Type: application/json; charset=utf-8
X-Korail-Event: payment.paid
X-Korail-Signature: sha256=<hex>

{ "event": "payment.paid", "tenantId": "…", "apiKeyId": "…",
  "data": { "rsvId": "…", "orderRef": "…", "amount": 59800, "approvalNo": "…" } }

브라우저 직접 호출 (CORS)

기본은 서버 간 호출입니다. 브라우저에서 직접 부르려면 콘솔 › API 키 상세의 허용 오리진에 도메인을 한 줄씩 등록하세요(https://partner.com — 경로 없이 도메인만). 등록하지 않은 키는 브라우저 호출을 받지 않습니다.

주의 — 브라우저에서 호출한다는 것은 API 키가 고객에게 노출된다는 뜻입니다. 그 키로 남의 예약을 취소하거나 결제를 일으킬 수 있으므로, 브라우저에 두는 키는 반드시 test 타입이거나 라이브 게이트가 꺼진 키여야 합니다. 실거래 키는 서버에만 두세요.
TEXT
OPTIONS /api/booking/availability      → 204 (등록된 오리진) / 403
POST    /api/booking/availability      → 응답에 Access-Control-Allow-Origin 포함

Origin 헤더가 없는 서버 간 호출은 이 검사를 거치지 않습니다.

프리플라이트가 허용하는 요청 헤더는 이 다섯입니다. 요청 서명을 켠 키는 X-Timestamp·X-Signature필수이므로 여기 함께 들어 있어야 합니다.

TEXT
access-control-allow-headers:
  Authorization, Content-Type, X-Api-Key, X-Timestamp, X-Signature

브라우저 호출이 안 될 때 — 오리진 문제로 단정하지 마세요. 헤더가 막히는 경우와 오리진이 막히는 경우는 증상이 같습니다. JS 에는 둘 다 TypeError: Failed to fetch 로만 오고, 응답을 읽을 수도 없습니다.

브라우저 콘솔의 원문을 보세요. … is not allowed by Access-Control-Allow-Headers 가 보이면 저희 쪽이 그 헤더를 막고 있는 것입니다 — 오리진을 아무리 등록해도 풀리지 않으니 알려 주세요. (2026-08-31 실제로 서명 헤더 둘이 목록에서 빠져 있었고, 파트너가 오리진을 세 개 등록하고도 계속 실패했습니다. 지금은 고쳐졌습니다.)

직접 확인하실 수도 있습니다:

BASH
curl -i -X OPTIONS https://…/api/booking/stations \
  -H 'Origin: https://partner.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type,x-timestamp,x-signature'

승차권 바우처 발송

API 키마다 발송 주체를 고릅니다(콘솔 › API 키 상세).

설정동작
우리가 발송 (기본)발권 성공 시 고객 이메일로 바우처를 보냅니다. 수신 주소는 reserve 요청의 email 값을 씁니다 — 이 값을 보내지 않으면 발송되지 않습니다.
파트너가 직접보내지 않습니다. ticket.issued 웹훅을 받아 직접 처리하세요.

같은 예약에 대해 바우처는 한 번만 나갑니다(폴러가 겹쳐 돌아도 중복 발송 없음). 발송 실패는 예약·결제를 되돌리지 않으며, 다음 pay 호출에서 재시도됩니다.

우리가 발송일 때 메일이 어떻게 보이는지 적어 둡니다 — 고객이 "이거 누가 보낸 메일이냐"고 물으실 때 답하실 수 있게.

항목
보낸사람파트너코드가 표시 이름입니다(예: ITS2026). 회사명은 여러 파트너가 같을 수 있어 코드를 씁니다. 주소는 저희 발송 계정입니다.
제목[파트너코드] 용산 → 서울 승차권이 발권되었습니다.
취소는 … 승차권이 취소되었습니다. 다른 언어는 그 언어의 표기를 씁니다.
로고콘솔에 등록하신 파트너 로고가 메일 머리에 실립니다 (콘솔 › 파트너 상세 › 로고). 등록하지 않으면 로고 없이 나갑니다.
언어reservelang → 키의 바우처 기본 언어 → 한국어 순입니다.
내용여정 · 좌석 · 인원 · 예매처 · 승차권 QR · 결제 금액. 취소 메일은 환불 내역을 앞세웁니다.

파트너가 직접 발송하시는 경우, 바우처에 필요한 값은 ticket.issued 웹훅에 다 들어 있습니다 — 좌석·인원별 운임· 좌석마다 다른 승차권 QR 주소까지. 저희 메일을 받아 볼 수는 없으니 위 표는 참고용입니다.

서명 검증

secret 은 발급받은 API 키를 sha256 한 hex 문자열입니다. 이 secret 으로 본문 원문을 HMAC-SHA256 한 값이 X-Korail-Signature 와 같아야 합니다. 서비스는 키 원문을 보관하지 않으므로(해시만 저장) 양쪽이 같은 secret 을 각자 계산합니다.

TEXT
secret   = sha256(API_KEY).hexdigest()
expected = "sha256=" + hmac_sha256(secret, request_body).hexdigest()

타임아웃은 5초이며 재시도하지 않습니다. 응답 지연이 예약·결제를 막지는 않지만, 수신 서버는 빠르게 2xx 를 반환하고 처리는 비동기로 넘기는 것을 권장합니다.

예약 세션 상태 #

각 예약은 세션으로 기록되며(콘솔 › 예약 세션에서 조회), 단계에 따라 상태가 바뀝니다. 아래가 기록되는 상태 전부이며, 각각 어떤 웹훅으로 나가는지 함께 적었습니다.

상태대응 웹훅의미
created dry-run(실거래 아님)으로 호출된 기록. 좌석은 잡히지 않았습니다.
reservedbooking.created 좌석 선점(미결제) — 결제 대기.
paidpayment.paid 철도사 결제 완료(발권은 아직).
ticketedticket.issued 발권 완료 — 표가 실제로 나온 시점.
cancelledbooking.cancelled 미결제 예약 취소 — 누군가 취소를 요청한 경우입니다.
expired 결제기한이 지나 철도사가 좌석을 회수했습니다. 아무도 취소를 누르지 않았지만 그 예약은 없습니다. 웹훅은 보내지 않습니다.
refundedrefund.completed 발권분 반환·환불 완료.
failed 처리 실패. 응답으로 즉시 알 수 있으므로 웹훅을 따로 보내지 않습니다.

createdbooking.created 는 다른 것입니다. 세션의 createddry-run 호출 기록이고, 웹훅 booking.created좌석이 실제로 잡힌 시점(세션 reserved)입니다. 나란히 놓고 보면 반드시 헷갈리는 지점이라 미리 밝혀 둡니다.

기한이 지난 선점은 expired 로 바뀝니다(2026-08-22부터). 저희가 주기적으로 철도사에 확인한 뒤 기록하므로, 기한 직후 잠깐은 아직 reserved 로 보일 수 있습니다. 그 사이 확실히 알아야 하면 sync 로 물어보세요.

bookings 조회에도 같은 값이 실립니다 — status: "expired". 결제 대기 목록을 그리신다면 reserved 만 세시고 expired 는 빼 주세요. 예전에는 둘이 구분되지 않아 결제 대기가 계속 쌓이기만 했습니다. status=reserved 로 거르면 만료된 건은 이제 빠집니다.

결제 전에 취소한 선점은 cancelled 입니다(2026-09-14부터). cancel 을 결제 전에 부르신 건이 여기 해당합니다. status=reserved 로 거르면 이것도 함께 빠지고, status=cancelled 로 따로 부르실 수 있습니다. 그전까지는 취소하신 선점이 계속 reserved 로 보였습니다 — 결제 대기 목록을 reserved 로 그리고 계셨다면 그 수가 줄어듭니다.

만료 웹훅은 보내지 않습니다. 새 이벤트를 흘리면 받을 준비가 안 된 쪽에 오류로 쌓입니다 — 필요하시면 말씀해 주세요, 그때 booking.expired 를 켜 드리겠습니다.