파트너 연동
Rail Connect 연동 가이드
파트너사가 KTX·SRT 좌석 조회부터 예약·결제·발권·취소·상태동기화까지 연동하기 위한 개발 문서입니다. 모든 요청은 발급받은 API Key 로 인증하고, 함께 받으신 Secret Key 로 서명합니다(요청 서명).
개요 · 예약 흐름 #
서비스는 파트너사의 주문 시스템을 대신해 코레일/SR에 실제 예약·결제·발권을 수행합니다. 고객 결제를 누가 받느냐에 따라 연동이 갈립니다(결제 모드).
- 역 목록
stations— 출발·도착 선택에 쓸 역 카탈로그를 받습니다. 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요(철도사마다 이름이 다른 역이 있습니다). - 좌석 조회
availability— 구간·일자로 열차별 잔여/매진·요금을 받습니다. - 좌석 선점
reserve— 미결제 상태로 좌석을 잡고 예약번호(rsvId)를 받습니다. - 고객 결제 — Partner 파트너 화면에서 받습니다(우리 서비스는 파트너 주문을 읽지 않습니다) · RAILCONNECT checkout 으로 결제 주소를 받아 고객을 보냅니다.
- 결제 · 발권 —
Partner pay 에
authorizations를 담아 호출 · RAILCONNECT 결제 웹훅을 받아 저희가 발권합니다(pay호출 불필요). - 취소 / 환불
cancel— 미결제는 취소, 결제분은 환불로 자동 분기합니다. RAILCONNECT 모드는 고객 환불까지 함께 처리합니다. - 예매 내역 조회
bookings— 회원 식별자로 그 회원의 예매 목록·상세를 받습니다(파트너 사이트의 “내 예매 내역”). - 실시간 상태 대사
sync— 예약번호로 지금 상태(유효/발권/취소)와 발권된 좌석을 확인합니다. 저희 DB 가 아니라 철도사에 직접 물어보므로 저희를 거치지 않은 변화도 잡힙니다.
발권은 파트너사 계정으로 이뤄집니다. 고객의 코레일·SRT 계정이 아니라, 콘솔에 등록한 파트너사의 철도사 계정으로 예약·결제·발권합니다. 그래서 승차권은 그 계정의 예매 내역에 들어가고, 고객은 철도사 앱에서 이 예약을 볼 수 없습니다 — 고객에게는 저희가 보내는 승차권 바우처가 전달됩니다.
같은 이유로 철도사 사이트·앱에서 그 예약을 직접 건드릴 수 있는 쪽은 계정을 가진 파트너사입니다. 그렇게 취소하면 저희 기록과 어긋나므로 sync 로 대사해야 합니다.
금액 계산(정상운임·운임할인·결제운임·발권수수료·고객 청구액)은 수수료 · 청구액에, 취소 전 환불 예상액은 환불 예상액에 있습니다.
(Partner 모드) 결제(pay)는 폴러 방식을 권장합니다. 결제 기한(코레일 10~20분) 안에 크론으로 주기 호출하면 코레일 응답 지연·브라우저 이탈에도 재시도됩니다. 체크아웃 흐름에 직접 매달지 마세요.
인증 · 라이브 게이트 #
모든 요청 헤더에 API 키를 담습니다. X-Api-Key 헤더도 허용됩니다.
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 는 보내지 않고 서명에만 씁니다 — 네트워크·프록시·로그 어디에도 남지 않고, 서명에 시각이 들어가 가로챈 요청을 그대로 재전송해도 통하지 않습니다.
헤더 세 개
Authorization: Bearer <API_KEY>
X-Timestamp: 1755100800
X-Signature: sha256=<hex>서명 대상(정본 문자열)
아래 넷을 줄바꿈(\n)으로 이어 붙인 문자열을 시크릿으로 HMAC-SHA256 한 뒤, 16진수로 적고 앞에 sha256= 를 붙입니다.
POST
/api/booking/reserve
1755100800
<본문을 sha256 한 16진수>| 메서드 | 항상 POST (대문자). |
| 경로 | /api/booking/… — 쿼리스트링과 도메인은 뺍니다. |
| 시각 | 유닉스 초(밀리초 아님). X-Timestamp 와 같은 값이어야 합니다. |
| 본문 해시 | 실제로 보내는 바이트를 sha256 한 값. 본문이 없으면 빈 문자열의 sha256. |
본문은 한 번만 직렬화하세요. 서명할 때와 보낼 때 JSON.stringify·json.dumps 를
각각 부르면 공백이나 키 순서가 달라져 서명이 어긋납니다 — 서명 실패의 가장 흔한 원인입니다.
먼저 문자열/바이트를 만들고, 그것을 서명한 뒤 그대로 전송하세요.
예제
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,
})import crypto from "node:crypto";
const API_KEY = "ksp_...", SECRET = "kss_...";
const BASE = "https://rail-connect.rideus.net", PATH = "/api/booking/reserve";
const body = JSON.stringify({ depName: "서울", arrName: "부산" }); // 한 번만 만든다
const ts = String(Math.floor(Date.now() / 1000)); // 초 단위
const hash = crypto.createHash("sha256").update(body).digest("hex");
const sig = "sha256=" + crypto.createHmac("sha256", SECRET)
.update(["POST", PATH, ts, hash].join("\n")).digest("hex");
await fetch(BASE + PATH, { method: "POST", body, headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json; charset=utf-8",
"X-Timestamp": ts,
"X-Signature": sig,
}});$body = json_encode(["depName" => "서울", "arrName" => "부산"],
JSON_UNESCAPED_UNICODE); // 한 번만 만든다
$ts = (string) time();
$msg = implode("\n", ["POST", $path, $ts, hash("sha256", $body)]);
$sig = "sha256=" . hash_hmac("sha256", $msg, $secret);실패했을 때
| 상태 | 응답 error | 원인 |
|---|---|---|
401 | 서명 헤더가 없습니다… | X-Timestamp 또는 X-Signature 누락. |
401 | 요청 시각이 N초 어긋났습니다… | 서버 시계 오차. 허용은 ±5분 — NTP 를 맞추세요. 밀리초를 보낸 경우도 여기로 옵니다. |
401 | 서명이 맞지 않습니다… | 시크릿·본문·경로 중 하나가 다름. 대개 본문을 두 번 직렬화한 경우입니다. |
Secret Key 는 발급 직후 한 번만 보입니다. 콘솔이 다시 보여 주지 않으니(봉인 저장) 발급받으신 값을 안전한 곳에 보관하세요. 분실하면 재발급해 드릴 수 있지만, 재발급 즉시 옛 값으로 만든 서명은 통하지 않습니다 — 반영하시는 동안 요청이 401 로 막힙니다.
공통 규약 #
기본 주소
아래 모든 경로는 이 주소 뒤에 붙습니다. 메서드는 전부 POST입니다.
BASE_URL · https://rail-connect.rideus.net
테스트와 운영은 주소가 같습니다. 어느 쪽으로 동작할지는
키 종류가 정합니다(ksd_ 개발용 / ksp_ 운영용).
별도의 스테이징 도메인은 없습니다.
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 상태코드
상태코드로 분기하지 마세요 — ok 와 code 로 판단하세요.
성공은 언제나 200 + ok:true 이지만, 실패 코드는 엔드포인트마다 다릅니다.
예를 들어 매진(SOLD_OUT)은 reserve 에서 502 로 옵니다 — 5xx 를
"서버 장애"로 읽고 재시도하면 매진된 열차를 계속 두드리게 됩니다.
availability 는 2026-08-27부터 코드에 맞는 상태로 답합니다.
예전에는 모든 실패가 502 라, 예매 구간 밖 날짜를 고른 것뿐인데
파트너 화면이 “예매 서버가 죽었다”로 읽는 일이 있었습니다. 다른 엔드포인트는
아직 예전 그대로이니 여전히 code 로 판단하세요.
| 상태 | 언제 |
|---|---|
200 | 성공(ok:true). pay 는 예약별로 갈리므로 results[].code 도 함께 보세요. |
400 | 본문 형식·필수값 오류(모든 엔드포인트). 그리고 checkout·pay·bookings·refund-quote 는 모든 실패가 400입니다. |
401 | API 키 없음·무효. 요청 서명을 켠 키라면 서명 누락·불일치도 여기로 옵니다. |
403 | 등록하지 않은 오리진에서의 브라우저 호출(CORS). |
500 | pay 처리 중 서버 오류. |
409 | availability 의 매진(SOLD_OUT). |
502 | reserve·cancel·sync 의 모든 실패(매진·미조회 포함).
availability 는 2026-08-27부터 코드에 맞는 상태로 답합니다 —
날짜·입력 문제는 400, 매진은 409, 철도사 쪽 문제일 때만 502. |
코레일 vs SRT 라우팅. reserve·cancel은 service("korail" | "srt")로 명시하거나, 생략 시 trainGradeName이 "SRT…"로 시작하면 SRT로 판단합니다. pay는 carrier로 한 철도사만 지정할 수 있고, 생략하면 코레일·SRT를 모두 처리합니다.
⚠️ 2026-09-01 KTX·SRT 통합운행 이후 reserve 는 SRT 를 받지 않습니다
(SRT_DISCONTINUED). SRT 로 팔리는 열차가 없어져 좌석을 잡을 수 없기 때문입니다 —
같은 구간을 KTX 로 예약하세요. cancel 은 그대로 SRT 를 받습니다:
통합 전에 잡아 둔 예약의 취소·환불이 막히면 안 되기 때문입니다.
역 목록 #
출발·도착 선택 화면에 쓰는 역 카탈로그입니다. 원본은 공공데이터(TAGO)이고 하루 한 번 다시 받습니다 — 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요.
| 요청 | 결과 | 무엇 |
|---|---|---|
{} | 344 | 전체(무궁화·ITX 정차역 포함) |
{"trainTypes":["KTX"]} | 62 | KTX 가 서는 역 |
{"trainTypes":["SRT"]} | 0 | ⚠️ 2026-09-01 통합운행으로 값이 없어졌습니다 (아래 참고) |
{"carrier":"korail"} | 343 | 코레일이 취급하는 역 — KTX 와 다릅니다 |
{"carrier":"srt"} | 32 | 저희가 SRT 로 예매할 수 있는 역 |
trainTypes 와 q·carrier 는 함께 쓸 수 있습니다(모두 만족하는 역만 옵니다).
trainTypes 안의 값끼리는 합집합입니다 — “둘 다 서는 역”이 아니라 “하나라도 서는 역”입니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
name | string | 역 이름. 호출할 때는 언제나 이 한국어 이름을 보냅니다. |
city | string | 소재 시·도. |
tagoId | string | 공공데이터 역 코드로, 다른 공공 API 와 맞출 때 쓰십시오. |
carriers | string[] | 저희가 예매할 수 있는 철도사. 예매 가능 여부로 거르려면 이 값을 보세요. |
trainTypes | string[] | 그 역에 서는 열차 종류 — carriers 와 다릅니다(아래 경고). |
carrierNames | object | 참고용 — 파트너는 name 만 쓰면 됩니다(우리가 바꿔 넘깁니다). |
names | object | 화면 표시용 다국어(en·ja·zh-CN·zh-TW). 모르는 역은 빈 객체 {} 입니다. |
name 을 그대로 availability·reserve 에 넘기세요.
철도사마다 이름이 다른 역이 있는데(울산 ↔ 코레일·SR 은 울산(통도사),
김천구미 ↔ SR 은 김천(구미)) 저희가 바꿔서 넘깁니다.
carriers 로 어느 철도사가 서는지 알 수 있습니다 — SR 전용역(평택지제)과
코레일 전용역(용산·서울 등)이 있어, 구간을 고를 때 걸러 주면 헛조회가 줄어듭니다.
⚠️ carriers 와 trainTypes 는 다릅니다.
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 는 화면에 보여 주기 위한 값이고, 조회·예약의 열쇠가 아닙니다.
{} // 전체
{ "q": "울산" } // 이름으로 검색(자동완성)
{ "trainTypes": ["KTX"] } // KTX 정차역만
// ⚠️ ["SRT"] 는 2026-09-01 통합운행 뒤 0개역입니다
{ "carrier": "srt" } // SR 예매 코드가 있는 역 (옛 예약의 취소·환불 경로){ "ok": true, "count": 344, "stations": [
{ "name": "울산", "city": "울산광역시", "tagoId": "NAT0…",
"carriers": ["korail", "srt"],
"trainTypes": ["KTX"],
"carrierNames": { "korail": "울산(통도사)", "srt": "울산(통도사)" },
"names": { "en": "Ulsan", "ja": "蔚山", "zh-CN": "蔚山", "zh-TW": "蔚山" } } ] }좌석 조회 #
구간·일자의 열차별 잔여/매진 상태와 요금을 조회합니다. 코레일·SRT를 함께 조회해 합칩니다.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
depName · arrName | string 필수 | 역 목록의 name 을 그대로. |
date · time | string 필수 | YYYYMMDD · HHmm. time 은 그 시각 이후만 받는 하한입니다. |
passengers | number 권장 | 예매하려는 인원. 좌석현황을 이 인원 기준으로 받습니다. |
paxBreakdown | object 권장 | 인원 구분(adults·children·seniors·toddlers). 이것만 보내도 됩니다(합으로 셉니다). |
lang | enum 선택 | 화면 언어 — 통화가 갈립니다. |
trainTypes | string[] 선택 | 받을 열차 종류. 비우면 전부(무궁화·ITX 포함). "KTX" 는 KTX-산천·KTX-이음까지 포함합니다. |
인원을 보내지 않으면 좌석현황은 1인 기준입니다. 2명을 태우려는데
1석만 남은 열차도 reservePossible:"Y" 로 옵니다 — 그대로 예약을 걸면
reserve 에서 SOLD_OUT 으로 떨어집니다.
passengers(또는 paxBreakdown)를 함께 보내면 코레일이
그 인원이 함께 앉을 수 있을 때만 예약가능으로 내려 줍니다.
코레일·SRT 모두 반영됩니다. 응답의 seatBasis 가 무슨 기준으로
조회했는지 알려 줍니다. 다만 잔여가 조회와 예약 사이에 바뀔 수 있으니,
reserve 의 SOLD_OUT 은 정상 흐름으로 다뤄야 합니다.
paxBreakdown 만 보내도 됩니다(합으로 셉니다).
유아(toddlers)는 좌석을 차지하지 않아 세지 않습니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
customerTotal | number | 일반실: 고객에게 보여 줄 금액(운임 + 발권수수료). |
specialTotal | number | 특실: 고객에게 보여 줄 금액. 특실이 없는 열차는 0 입니다. |
generalSeatStatus | enum | 등급별 상태. available | soldout | none — 좌석코드 대신 이 값으로 그리세요. |
discountRate | number | 철도사가 밝힌 할인율(%) — 할인이 없으면 0. 저희가 계산한 값이 아니라 받은 값입니다(코레일은 예약안내 문구, SR 은 trainDiscGenRt). 일반실 운임 기준입니다. |
listFare · discount | number | 일반실 정상운임 · 운임할인. listFare − discount = generalFare 가 항상 맞습니다. 철도사가 정가를 주지 않아 저희가 되짚은 값입니다. |
specialListFare | number | 특실 정상운임 · 운임할인 — 추가요금에는 할인이 안 붙습니다. |
generalFare | number | 철도사 일반실 운임 — 참고용(수수료 빠짐). |
firstSurcharge | number | 특실 추가요금 — 참고용. |
ticketFee | number | 각 등급의 발권수수료 — 내역을 따로 적을 때만. |
depDate · depTime | string | 출발·도착 일시. 자정을 넘기는 열차는 날짜가 다릅니다. |
trainTypeName | string | KTX · KTX-산천 · SRT · ITX-새마을 · ITX-마음 · 무궁화호 · S-train 등. |
reservePossible | enum | "Y" 는 둘 중 하나라도 가능하다는 뜻 — 행 전체를 흐리게 할 때만 쓰세요. |
seatBasis | object | 좌석현황이 몇 명 기준인지 — 코레일·SRT 모두 보낸 인원을 반영합니다. |
sources | object | 어느 철도사를 실제로 물어봤는지. ok | timeout | error | skipped | off. |
ticketFeeRate · feeBasis | number 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 | 각 등급의 발권수수료 — 내역을 따로 적을 때만 | 참고용 |
일반실 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·specialTotal 이 0 입니다 — 일반실 값을 넣어 두면 특실 없는 무궁화호에 특실 요금이 뜨기 때문입니다.
고를 수 있는 날짜에는 끝이 있습니다 — 그런데 며칠 뒤인지는 고정이 아닙니다. 철도사가 기간을 나눠 열고, 명절 특별수송기간이 앞에 걸리면 그만큼 당겨집니다. (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_PERIOD 의 error 에는 철도사 안내문이
그대로 옵니다 — 예매 일정·대상·자격 조건이 적힌 여러 줄짜리 글입니다.
{ "ok": false, "code": "DATE_SPECIAL_PERIOD", "stage": "search",
"error": "2026년 추석 특별수송기간 …합니다.\n\n1. 대상열차 : …\n2. 예매일자 : …" }줄바꿈은 저희가 보냅니다. 값 안에 \n 이 그대로 들어 있습니다
(위 안내문은 8개). 한 줄로 보인다면 HTML 이 줄바꿈을 공백으로 접기 때문이며,
CSS 한 줄로 살릴 수 있습니다.
white-space: pre-line;innerHTML 에 error 를 그대로 넣지 마세요.
철도사가 주는 문자열이라 내용을 저희가 통제하지 못합니다.
textContent 로 넣으시거나, 이스케이프한 뒤
\n → <br> 로 바꿔 주세요.
이 안내문은 한국어로만 옵니다
저희가 만든 문구가 아니라 철도사 응답 원문이고, 철도사가 영어판을 주지 않습니다. 저희가 번역해 드리지도 않습니다 — 내용이 날짜 · 대상 · 자격 조건 이라(사전예매 기간, 경로·장애인·임산부·국가유공자 한정 등) 잘못 옮기면 고객이 예매 기회를 놓치고, 문구는 명절마다 바뀌어 미리 번역해 둘 수도 없습니다.
권해 드리는 방식은 code 로 분기해 파트너사 언어로 안내하고,
철도사 원문은 접어서 함께 보여 주는 것입니다.
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" | 그 등급이 없는 열차 — 칸을 비우거나 —.
매진으로 그리면 거짓말입니다(특실 없는 무궁화호를 '특실 매진'으로 보여 주게 됩니다) |
일반실 = 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 로 알 수 있습니다.
"sources": { "korail": "ok", "srt": "off" } // ok | timeout | error | skipped | off2026-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편이라, 시도만 쌓여 계정이 잠길 위험이 있습니다. 다시 필요하시면 저희에게 말씀해 주세요(콘솔에서 키별로 켭니다).
왕복
왕복은 가는 편과 오는 편을 각각 호출합니다 — 이 엔드포인트는 한 방향만 봅니다.
결제는 checkout 의 legs 로 한 번에 묶을 수 있습니다
(고객이 두 번 결제하지 않습니다).
열차 종류를 걸러 받을 수 있습니다. 요청에
"trainTypes": ["KTX"] 를 넣으면 그 종류만 옵니다(무궁화·ITX 제외).
비우면 전부 옵니다 — 지금까지와 같습니다.
"KTX" 는 KTX-산천·KTX-이음까지 포함합니다(앞부분으로 맞춥니다).
응답을 받아 직접 거르셔도 되고, 이 값을 쓰면 주고받는 양이 줄어듭니다.
⚠️ "SRT" 는 넣지 마세요 — 2026-09-01 통합운행 뒤로 걸리는 열차가 없습니다.
옛 SRT 노선의 열차는 이제 KTX·KTX-산천 이라는 이름으로 옵니다.
{ "depName": "서울", "arrName": "부산", "date": "20260810", "time": "0900",
"passengers": 2,
"paxBreakdown": { "adults": 1, "children": 1 },
"lang": "en",
"trainTypes": ["KTX"] }{ "ok": true, "count": 42,
"ticketFeeRate": 10.0,
"feeBasis": "1인 일반실 기준 — 확정 금액은 reserve 응답을 쓸 것",
"seatBasis": { "passengers": 2, "korail": "요청 인원 기준", "srt": "요청 인원 기준" },
"sources": { "korail": "ok", "srt": "ok" },
"trains": [
{ "trainNo": "101", "trainType": "00", "trainTypeName": "KTX",
"generalSeat": "11", "specialSeat": "12", "reservePossible": "Y",
"generalSeatStatus": "available", "specialSeatStatus": "soldout",
"reservePossibleName": "44,800원\n25%할인",
"depDate": "20260810", "depTime": "090000",
"arrDate": "20260810", "arrTime": "115000",
"depName": "서울", "arrName": "부산",
"listFare": 59800, "discount": 15000,
"specialListFare": 83700, "specialDiscount": 15000,
"generalFare": 44800, "firstSurcharge": 23900,
"discountRate": 25.0,
"ticketFee": 4480, "customerTotal": 49280,
"specialFare": 68700, "specialTicketFee": 6870,
"specialTotal": 75570 } ] }좌석 선점 #
좌석을 미결제 상태로 잡고 예약번호(rsvId)를 돌려줍니다. 이후 결제 기한 안에 pay로 결제해야 유지됩니다.
siteName 은 필수입니다. 예약을 받은 사이트 이름을 보내 주세요 — 한 파트너가 사이트를 여러 개 운영하면 파트너명만으로는 어디서 온 예약인지 알 수 없습니다.
이 값은 승차권 바우처의 “예매처” 항목과 저희 콘솔의 예매 목록·예약 세션에 그대로 표시됩니다. 바우처 꼬리말이 “변경·취소는 구매하신 사이트에서 진행해 주세요”라고 안내하는데, 그 사이트가 어디인지 적히지 않으면 고객은 갈 곳을 모릅니다. 발권 취소 안내 메일에도 함께 나갑니다.
예매처 APHRS 2026 Transport Portal [바로가기]siteUrl 은 선택이지만 넣어 주시길 권합니다 — 바우처의 [바로가기] 버튼과 콘솔의 링크가 됩니다(없으면 이름만 표시). http:// 또는 https:// 로 시작해야 하며, 아니면 INVALID_REQUEST 로 거절합니다.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
siteName | string 필수 | 예약을 받은 파트너 사이트 — 바우처·콘솔에 표시됩니다. |
siteUrl | string 권장 | 고객 안내에 링크로 씁니다. |
depName · arrName | string 필수 | 조회에 쓴 것과 같은 값을 그대로 넘기면 됩니다(stations 의 name). 철도사가 부르는 이름이 다르면 저희가 바꿔 넘깁니다 — 파트너가 캐리어별 이름을 구분할 필요는 없습니다. |
date · time | string 필수 | YYYYMMDD · HHmm. 어느 열차를 잡을지는 trainNo 가 정합니다 — time 은 조회에 쓴 시각을 그대로 넘기셔도 되고, 그 열차의 출발 시각을 넘기셔도 됩니다. 어느 쪽이든 같은 열차를 잡습니다. |
trainNo | string 필수 | 좌석 조회 응답의 열차번호를 그대로 넘기세요. 이 값이 열차를 특정하는 열쇠입니다. 앞의 0 은 있어도 없어도 같습니다(001 = 1). |
seatType | enum 필수 | "standard"(일반실) | "first"(특실) |
passengers | number object 필수 | 인원과 그 구분. 유아(toddlers)는 좌석을 차지하지 않아 세지 않습니다. |
service | enum 선택 | 생략 시 trainGradeName 으로 추론. |
live | boolean 쓰지 않음 | 더 보지 않습니다(2026-08-27부터). 실거래 여부는 콘솔의 라이브 게이트가 정합니다 — 아래 안내를 보세요. 보내셔도 무시되며, 오류가 나지는 않습니다. |
customerKey | string 권장 | 파트너사의 회원 식별자입니다. 예약번호는 철도사가 발급해 파트너 회원과 이어지지 않으므로, 이 값을 보내 두면 bookings 로 “이 회원의 예매 내역”을 바로 받을 수 있습니다. 개인정보를 담지 마세요 — 이름·연락처가 아니라 내부 회원 ID 같은 값이면 됩니다. 비회원 예매라 회원키가 없어도 됩니다 — 그때는 email·phone 으로 조회합니다(그래서 예약자 정보를 보내 두는 편이 좋습니다). |
lastName · firstName | string 권장 | 예약자. 예약자·인원 정보는 예약 세션에 그대로 저장되어 콘솔 데이터 > 예매 목록에서 조회됩니다. 안 보내면 그 자리가 비며, 나중에 채울 방법이 없습니다. email 이 없으면 승차권 바우처가 발송되지 않습니다. 국가번호는 phone 에 붙여 보내도 됩니다. |
lang | enum 권장 | 고객이 보고 있던 화면의 언어입니다 — 승차권 바우처 메일을 이 언어로 보냅니다. 지원: 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 면 콘솔에서 켜 달라고 요청해 주세요.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
rsvId | string | 이후 모든 호출에 쓰는 예약번호. |
customerTotal | number | 고객에게 청구할 확정 금액. 요율을 따로 곱하지 말고 이 값을 그대로 쓰세요. |
currency · fxRate · usd | string number object | lang 이 한국어가 아니면 환산액이 함께 옵니다(통화 · 환율). |
train.price | number | 예약 총액(인원·등급 반영) — 수수료 계산 기준. |
reservation | string | 결제 기한입니다(코레일 기준 10~20분). 그 전에 결제하지 않으면 좌석이 풀립니다 — Partner 모드는 이 시각까지 pay 를, RAILCONNECT 모드는 고객이 결제창을 마쳐야 합니다. |
reservation.seat_no | string number | 잡힌 좌석과 그 수. |
reservation.rsv_id | string | 최상위 rsvId 와 같은 값입니다 — 예전 형태를 읽는 연동을 위해 남겨 둔 것이니, 새로 붙이신다면 최상위 rsvId 를 쓰세요. |
여러 계정에 순차 재시도합니다. rsvId를 보관해 이후 checkout/pay/cancel/sync/bookings에 그대로 넘기세요. dry-run이면 mode:"dry-run"으로 실제 좌석은 잡지 않습니다(rsvId도 없습니다).
{
"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"
}{ "ok": true, "stage": "reserved", "service": "korail", "mode": "live",
"rsvId": "320260844833693",
"ticketFeeRate": 10.0, "ticketFee": 11960, "customerTotal": 131560,
"currency": "USD", "fxRate": 1380.0,
"usd": { "fare": 86.67, "ticketFee": 8.67, "customerTotal": 95.33 },
"train": { "price": 119600,
"trainNo": "101", "depTime": "090000", "arrTime": "115000" },
"reservation": { "rsv_id": "320260844833693",
"train_no": "101", "price": 119600,
"seat_no": "5A", "seat_no_count": 2,
"buy_limit_date": "20260810", "buy_limit_time": "092000" } }결제수단 목록 #
checkout 의 method 에 넣을 값의 출처입니다.
결제수단 이름은 저희 콘솔에 등록된 값과 글자까지 같아야 하므로, 받아 적지 말고 이 목록에서 고르게 하세요.
키에 맞는 것만 옵니다. 테스트 키에는 테스트 결제수단만, 운영 키에는 운영 결제수단만 내려갑니다 — 화면에 세운 뒤 고객이 고른 다음에야 막히는 일이 없게 하려는 것입니다.
names 는 화면에 보여 줄 표기이고, checkout 에는 언제나 method 를 보냅니다.
이 엔드포인트는 RAILCONNECT 결제 모드 전용입니다. 파트너 결제 모드는 결제수단을 파트너 결제창에서 고르므로 호출할 일이 없습니다.
{}{ "ok": true, "count": 2, "env": "test", "methods": [
// method 를 그대로 checkout 에 넣으세요
{ "method": "국내카드", "names": { "en": "Credit card", "ja": "クレジットカード" } },
// 등록된 표기가 없으면 빈 객체 {} 입니다
{ "method": "간편결제", "names": {} } ],
"warning": null } // 값이 있으면 쓸 수 있는 결제수단이 없다는 뜻통화 · 환율 #
요청에 lang 을 실으면 보여 줄 가격과 실제 청구 통화가 함께 갈립니다. 규칙은 하나입니다.
| 언제 | 통화 |
|---|---|
lang 이 ko 이거나 없음 | KRW — 원화 그대로 |
| 그 밖의 언어 | USD — 실시간 환율로 환산 |
| 결제수단이 해외카드 | USD — 언어와 무관합니다 |
해외카드는 한국어 화면이어도 달러입니다. 해외 카드사는 원화 청구를 받지 못하는 계약이 흔하고, 받더라도 고객에게 이중 환전이 붙습니다.
어느 응답에 오나
| 엔드포인트 | 달러 값 |
|---|---|
| availability | 열차마다 usd — 목록에 쓰는 값 |
| reserve | usd — 확정 금액(인원·등급 반영) |
| checkout | chargeAmount — 실제로 긁는 금액 |
환산액(usd)은 통화와 상관없이 항상 옵니다. 한국어 화면(원화 결제)에도 함께 내려 드립니다 — “45,000원 (약 $32.6)”처럼 병기하는 화면이 있는데, 그때 파트너가 자기 환율로 계산하면 저희가 긁는 금액과 어긋납니다.
⚠️ 무엇을 긁을지는 currency 가 정합니다. currency: "KRW" 면 카드에는 원화가 긁힙니다 — usd 는 보여 주기 위한 값이지 청구액이 아닙니다. 실제 청구액은 언제나 checkout 의 chargeAmount 입니다.
lang 이 통화를 정합니다(해외카드는 checkout 의 method 도 봅니다). 환율은 결제 시점 값으로 고정되므로 단계 사이에 값이 조금 달라질 수 있습니다.
조회 응답
lang 이 한국어가 아니면 열차마다 usd 가 함께 옵니다. 원화 필드는 그대로 둡니다 — 정산의 기준이고 기존 코드가 이미 쓰고 있기 때문입니다.
{ "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이면 fxRate 와 fxMarketRate 가 같습니다. 두 값을 모두 내려 드리는 이유는, 고객이 “왜 이 금액이냐”고 물었을 때 설명할 수 있어야 하기 때문입니다.
시장 환율은 하나은행 고시환율입니다(기본 매매기준율). 국내 은행·카드사
정산이 대개 그 값을 기준으로 해 대조하기 가장 쉽고, 장중에 움직입니다.
저희가 어느 종류를 쓰는지(매매기준율 · 현찰 살 때/팔 때 · 송금 보낼 때/받을 때)는
콘솔에서 정하며, 바꾸면 fxMarketRate 가 그 값으로 바뀝니다.
환율은 최대 1시간 캐시됩니다. 매 조회마다 바깥을 두드리면
그쪽이 막혔을 때 결제가 통째로 멈추기 때문입니다. 고시 시점과 결제 시점 사이에
값이 조금 달라질 수 있으니, 화면에 띄울 최종 금액은 checkout 응답의
chargeAmount 를 쓰세요.
결제
checkout 응답의 amount 는 언제나 원화입니다(정산 기준). 고객 카드에 실제로 찍히는 값은 chargeAmount(currency 단위)이고, sdk.totalAmount 도 같은 값입니다.
환율을 확인할 수 없으면 달러로 열지 않습니다. 조회는 원화로 되돌리고 warning 을 실으며, checkout 은 CONFIG_MISSING 으로 거절합니다 — 오래된 환율로 긁으면 고객이 화면에서 본 금액과 다른 값이 카드에 찍힙니다.
환불은 결제한 금액 그대로 돌아갑니다. $32.20 를 받았으면 $32.20 를 돌려드립니다(환율이 그새 움직여도 동일). 왕복 한 편만 취소하면 원화 비율만큼 나눠 환불합니다.
환율은 결제 시점 값으로 고정됩니다. 조회와 결제 사이에 환율이 바뀌면 청구액이 조금 달라질 수 있으니, 결제 직전 checkout 응답의 chargeAmount 를 최종 금액으로 보여 주세요.
결제창 발급 #
RAILCONNECT 결제모드 전용. 선점(reserve)이 끝난 예약에 대해 고객이 결제할 주소를 발급합니다. 그 주소로 고객을 보내면 결제·검증·철도사 발권은 저희가 처리합니다. 파트너 결제모드는 이 엔드포인트를 쓰지 않고 pay 로 진행합니다.
요청 — 편도 필드
| 필드 | 타입 | 설명 |
|---|---|---|
rsvId | string 필수 | reserve 로 잡은 예약번호. 왕복이면 legs 로 대신 보냅니다. |
legs | object[] 필수 | 왕복 — 가는 편·오는 편의 rsvId·fare. 한 결제에 담을 수 있는 구간은 2개까지입니다. |
method | string 필수 | methods 가 준 값 그대로 (글자까지 같아야 합니다). methodName 으로 보내도 됩니다. |
fare | number 필수 | 결제운임(철도사 청구액). 발권수수료는 저희가 더합니다. |
lang | enum 선택 | 화면 언어 — 통화가 갈립니다. |
orderRef | string 선택 | 파트너 주문식별자. |
가는 편·오는 편을 legs 에 함께 담으면 결제창 하나로 묶입니다. 고객이 두 번 결제하지 않습니다. 각각 reserve 로 선점한 뒤 그 예약번호를 넣으세요.
발권수수료는 구간마다 계산해 합칩니다(합계에 한 번 매기면 반올림 때문에 구간별 기록과 어긋납니다). 위 예시라면 47,400+4,740 과 53,700+5,370 을 더해 111,210원이 청구됩니다. 한 결제에 담을 수 있는 구간은 2개까지입니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
payUrl | string | ① 고객을 여기로 보내면 끝입니다. |
sdk | object | ② 직접 결제창을 열 때 쓰는 값 (아래 참고). |
paymentId | string | 우리 결제번호. 바꾸지 마세요. |
amount | number | 고객 청구액(원) = fare + 발권수수료. 언제나 원화입니다. |
chargeAmount · currency | number string | 카드에 실제로 찍히는 금액과 통화. |
fxRate | number | 적용 환율 (1 USD = 몇 원). |
legs | object[] | 구간별 fare·ticketFee·customerTotal. 왕복이면 2건. |
orderName | string | 결제창·카드전표에 찍히는 이름. |
env | string | 키 타입이 정합니다(test 키 → test 채널). |
apiVersion | string | 채널이 쓰는 포트원 모듈 버전. |
warning | string | 값이 있으면 payUrl 을 만들지 못한 것. |
결제창을 여는 두 가지 방법
| ① payUrl 간단 | 고객을 payUrl 로 보냅니다. 저희 결제 화면이 열리면서 곧바로 포트원 결제창이 뜹니다. 포트원 SDK·채널·복귀 주소를 저희가 다 다룹니다. |
| ② sdk 화면 안 바뀜 | 파트너 화면을 그대로 둔 채 그 위에 결제창만 띄웁니다. 응답의 sdk 값을 포트원 브라우저 SDK 에 그대로 넘기면 됩니다. |
② 파트너 화면에서 바로 열기
sdk 안의 값은 손대지 말고 그대로 넘기세요. 금액·채널·웹훅 주소가 모두 들어 있습니다.
"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"] } // ⚠ 반드시 그대로 넘기세요<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 로 확인하세요 */ }<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 입니다.
{
"rsvId": "…",
"method": "국내카드",
"lang": "en",
"fare": 40400,
"orderRef": "주문식별자"
}{
"legs": [
{ "rsvId": "가는편-예약번호", "fare": 47400 },
{ "rsvId": "오는편-예약번호", "fare": 53700 }
],
"method": "국내카드",
"orderRef": "주문식별자"
}{
"ok": true, "stage": "checkout",
"paymentId": "rd_…",
"payUrl": "https://…/pay/rd_…",
"sdk": { … },
"env": "test",
"amount": 44440,
"currency": "USD",
"chargeAmount": 32.20,
"fxRate": 1380.0,
"fare": 40400, "ticketFee": 4040, "ticketFeeRate": 10.0,
"legs": [ { "rsvId": "…", "fare": 47400, "ticketFee": 4740,
"customerTotal": 52140 } ],
"method": "국내카드",
"orderName": "(KTX) 편도 서울역 → 부산역 승차권",
"apiVersion": "v1",
"warning": null
}결제 · 발권 #
서비스는 파트너 주문을 읽지 않습니다. 고객 결제가 끝난 예약만 authorizations에 담아 전달해야 결제됩니다(승인 없는 예약은 결제하지 않음).
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
authorizations | object[] 필수 | 고객 결제가 끝난 예약만 담습니다 — 승인 없는 예약은 결제하지 않습니다. |
authorizations[] | string 필수 | 예약번호와 파트너 주문식별자. |
authorizations[] | number string 필수 | 고객이 실제로 결제한 금액. 코레일 청구액이 이보다 크면 중단합니다. |
authorizations[] | object 권장 | 고객이 실제로 결제한 수단. 파트너 PG 로 받은 건이면 꼭 보내 주세요 — 콘솔 '예매 목록'의 결제수단·승인번호가 이 값입니다(고객 카드전표와 대조). |
live | boolean 쓰지 않음 | 더 보지 않습니다(2026-08-27부터). 실거래 여부는 콘솔의 라이브 게이트가 정합니다. 보내셔도 무시됩니다. |
carrier | enum 선택 | 지정 시 그 철도사만. |
rsvId | string 선택 | 지정 시 그 예약 하나만. |
payment 를 보내지 않으면 우리가 철도사에 낼 때 쓴 카드가 결제수단으로 남습니다. 고객이 낸 수단과 다르므로, 파트너 PG 를 쓰신다면 반드시 함께 보내 주세요. 카드번호 전체는 보내지 마세요 — 마스킹된 표시용 문자열이면 충분합니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
results[].status | enum | paid · skipped · failed · paid-unrecorded · dry-run (아래 설명). |
results[].code | string | 건너뛴 이유의 기계 판독용 코드(있을 때만). ALREADY_PAID · NOT_IN_CARRIER · UNVERIFIED 등. |
results[].settled | bool | true 면 더 시도할 것이 없는 확정 상태입니다 — 재시도하지 마세요. |
results[].seats | object[] | 인원 수만큼. 발권 직후 조회한 값이라 못 얻으면 빈 목록입니다. |
results[].approvalNo | string | 카드 승인번호. SRT 는 제공하지 않아 빈 값입니다. |
results[].payLimitAt | string | dry-run·방치홀드 결과에는 결제 기한이 함께 옵니다. |
ticketFeeTotal | number | 금액이 확정된 건만 더한 값 — 건너뛴 건은 빠집니다. |
holdSweep | object | 방치 홀드 자동취소 결과. |
vouchersSent | number | 우리가 보낸 승차권 바우처 수. |
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 로 실제 상태를 확인한 뒤 재시도하세요.
{
"live": true,
"carrier": "korail",
"rsvId": "…",
"authorizations": [
{ "rsvId": "…", "orderRef": "주문식별자", "paidAmount": 119600, "currency": "KRW",
"payment": { "method": "신한카드 5432-****-****-1234", "approvalNo": "16004215" } }
]
}{ "ok": true, "mode": "live", "issueTicket": true, "authorized": 1,
"results": [ { "rsvId": "…", "orderRef": "…", "carrier": "korail",
"status": "paid", "approvalNo": "…", "amount": 119600,
"seats": [ { "carNo": "3", "seatNo": "5A" },
{ "carNo": "3", "seatNo": "5B" } ],
"ticketFee": 11960, "customerTotal": 131560,
"payLimitAt": "2026-09-06T09:20:00" } ],
"ticketFeeTotal": 11960, "customerTotal": 131560,
"carriers": [ … ], "accounts": [ … ],
"holdSweep": { "requested": false, … },
"vouchersSent": 1 }방치 홀드 자동취소 (코레일) #
결제창에서 이탈해 결제로 이어지지 못한 reserve 좌석을, 코레일 결제기한 전에 취소해 되돌립니다. pay 요청에 아래 필드를 추가하면 켜집니다(코레일 전용).
{
"live": true,
"authorizations": [ … ],
"sweepStale": true,
"heldRsvIds": ["…", "…"],
"sweepStaleMinutes": 10
}요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
sweepStale | boolean 필수 | 방치 홀드 자동취소 opt-in. |
heldRsvIds | string[] 필수 | 결제 진행 중이라 취소하면 안 되는 예약번호. |
sweepStaleMinutes | number 선택 | 방치 판정 나이, 기본 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분이라 앞당길 이득이 커서 스윕을 둡니다.)
취소 · 환불 #
미결제 예약은 취소, 이미 결제·발권된 예약은 환불로 자동 분기합니다. 다인원 예약은 전 인원 티켓을 함께 환불합니다.
열차가 출발한 뒤에는 취소할 수 없습니다.(2026-09-07 변경 — 그 전에는 도착 기준이었습니다.)
철도사 규정상 출발한 열차의 반환은 역 창구 신청이라, 사이트에서 걸어 봐야 실패하거나 표만 물리고 환불이 어긋납니다.
출발 시각이 지난 예약에 cancel 을 부르면 stage: "policy" 로 거절합니다.
{ "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 로 취소를 부르면, 물릴 표가 없습니다. 그때는 오류가 아니라 기록을 맞추고 고객 환불까지 처리한 결과가 옵니다.
{ "ok": true, "stage": "refunded", "rsvId": "…",
"alreadyCancelledAtCarrier": true, // 우리가 물린 게 아니라 이미 반환돼 있었다
"refund": { "applied": { "carrierFee": null, … } } }환불액 규칙은 평소 취소와 같습니다. 다만 철도사 위약금(carrierFee)은 null 입니다 —
그쪽 화면에서 반환돼 얼마를 뗐는지 저희가 알 수 없습니다.
철도사 계정을 하나라도 확인하지 못하면 이 처리를 하지 않습니다. 대신 RESERVATION_NOT_FOUND
가 돌아옵니다 — “로그인 실패”를 “표가 없다”로 읽으면 살아 있는 표를 환불하게 되기 때문입니다.
이 코드를 받으면 재시도하지 말고 콘솔에서 확인하세요.
{ "rsvId": "…", "service": "korail", "live": true }
// service 생략 시 trainGradeName 으로 코레일/SRT 판단. 힌트가 없으면 양쪽을 모두 확인.{ "ok": true, "stage": "cancelled", "rsvId": "…" } // 미결제 취소
{ "ok": true, "stage": "refunded", "rsvId": "…", "refund": { … } } // 결제분 환불
// rsv_id 도 같은 값으로 함께 옵니다(구버전 호환).환불 예상액 #
반환하기 전에 환불 예상액을 계산합니다. 철도사(코레일·SR)에 접속하지 않으므로 부작용이 없고, 예약이 실제로 있는지는 확인하지 않습니다 — 요청에 담긴 운임·출발시각을 그대로 씁니다.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
rsvId | string 권장 | 결제 이력에서 운임·출발시각·발권수수료를 가져옴 (source:"payment"). |
fare | number 필수 | 결제운임(철도사 청구액). 정상운임·고객 결제액이 아님. |
carrier | enum 필수 | korail | srt |
depDate · depTime | string 필수 | depAt(ISO 8601) 로 대신 보낼 수 있음. |
arrDate · arrTime | string 선택 | 있으면 도착 후 반환 불가를 판정. |
ticketFeePaid | number 선택 | 생략하면 현재 요율로 추정(ticketFeeEstimated:true). |
rsvId 하나만 보내는 것을 권장합니다. 우리가 결제한 예약이면 결제 때 남긴 운임·출발시각·발권수수료를 그대로 씁니다 — 그 사이 요율을 바꿔도 고객이 실제로 낸 금액으로 계산됩니다. 이미 반환된 건이면 예상액이 아니라 그때 적용한 실제 값과 alreadyRefunded:true 가 옵니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
refundable | boolean | 반환할 수 있는가. 출발 시각이 지난 예약은 false 와 reason 만 옵니다. |
charged | number | 고객 청구액 = fare + ticketFeePaid. |
cancelFee | number | 취소수수료 = charged 의 5%, 10원 올림. 요율을 매긴 기준은 charged 입니다. |
refundAmount | number | 고객 환불액 = charged − cancelFee. |
customerLoss | number | 고객 총손실 = charged − refundAmount (= 취소수수료). |
carrierFee · carrierBand | number string | 철도사 반환위약금(추정) — 환불액에서 빼지 않음. |
cancelBand | string | 적용한 취소수수료 구간. |
estimated | boolean | 추정값인지. |
source | string | rsvId 로 물었고 결제 이력을 찾았을 때 "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:false 와 reason 만 오고 금액 필드는 null 입니다.
이미 반환된 건은 alreadyRefunded:true · estimated:false · refundedAt 과 함께
그때 적용한 실제 값이 옵니다.
{ "rsvId": "…" }
// 또는 직접 지정 — 우리가 결제하지 않은 예약
{
"fare": 40400,
"carrier": "korail",
"depDate": "20260826",
"depTime": "1000",
"arrDate": "20260826",
"arrTime": "1250",
"ticketFeePaid": 4040
}{
"ok": true, "stage": "quoted", "carrier": "korail",
"refundable": true,
"fare": 40400,
"ticketFeePaid": 4040,
"charged": 44440,
"cancelFee": 2230,
"cancelFeeRate": 5.0,
"cancelFeeBase": 44440,
"cancelBand": "평일 · 출발 3시간 전~출발 직전",
"refundAmount": 42210,
"customerLoss": 2230,
"carrierFee": 2020,
"carrierBand": "평일 · 출발 3시간 전~출발 직전",
"ticketFeeEstimated": false,
"estimated": true,
"rsvId": "…", "source": "payment"
}예매 내역 조회 #
회원의 예매 내역을 목록·상세로 돌려줍니다. 파트너 사이트의 “내 예매 내역” 화면에 그대로 쓰시면 됩니다.
예약번호는 철도사가 발급해 파트너 회원과 이어지지 않습니다. 그래서 reserve 때 보낸 값으로 찾습니다 — 회원이면 customerKey, 비회원이면 예약자 정보(이메일·전화번호)입니다.
이름을 다른 언어로 보여 주실 때 — 저희는 철도사가 준 한국어 원문을 그대로 싣습니다. 옮겨 쓰실 값은 이렇게 얻으세요.
| 무엇 | 어디서 |
|---|---|
| 역 이름 | stations 응답의 names
— 역마다 13개 언어가 딸려 옵니다. 표기의 정본이라 로컬 표를 만들지 마세요
(역이 늘거나 표기가 바뀌면 뒤처집니다). |
| 열차 이름 | 응답에 영문이 함께 옵니다 —
trainTypeNameEn · trainEn(availability 는
trainTypeNameEn). 국문·영문 두 가지뿐이고, 그 밖의 언어
화면에서도 영문을 쓰시면 됩니다. |
저희가 모르는 등급이면 영문 키가 비어서 옵니다 — 지어내지 않습니다. 그때는 한국어 원문을 쓰세요. 새 등급이 생기면 알려 주시면 넣겠습니다.
회원 목록
{ "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 로 옵니다. 좌석마다 결제운임·할인율·발권수수료·소계가 붙어, 고객에게 보여 줄 표를 그대로 그릴 수 있습니다.
"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 가 비어 있습니다(요율이 바뀌었거나 좌석등급이 섞인 경우). 없는 표를 지어내 보여 주는 것보다 안 보여 주는 편이 낫습니다.
source 가 carrier 면 철도사가 준 인원별 금액입니다 — 가장 정확합니다. 합계에서 비율로 나눈 값이 아닙니다(할인이 인원 종류마다 다르게 붙어 나누면 둘 다 틀립니다).
승차권 QR
발권된 코레일 승차권은 상세 응답에 QR 이미지 주소가 함께 옵니다. 파트너가 직접 바우처를 보내실 때 <img> 로 그대로 걸면 됩니다 — QR 라이브러리를 붙이실 필요가 없습니다.
{ "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 가 없을 수 있습니다. 그때는 예약자 정보로 찾습니다.
{ "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"] 등)을 알려 줍니다 — 보낸 값이 쓰였는지 응답만 보고 확인할 수 있습니다.
{ "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": "…" }
] }status 는 reserved(선점만 됨) · cancelled(결제 전에 취소) · expired(결제기한 경과로 좌석 회수) · paid(발권) · refunded(결제 뒤 취소·환불) · failed(결제 실패)입니다. cancelled 와 refunded 는 다릅니다 — 앞은 돈이 오가기 전에 접은 것이고, 뒤는 결제한 뒤 되돌려준 것입니다. truncated:true 면 limit 에 걸려 잘린 것이니 더 크게 요청하세요(최대 200).
한 건 상세
{ "rsvId": "…" }{ "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 를 쓰더라도 보이지 않습니다.
실시간 상태 대사 #
예약 조회는 이 엔드포인트로 합니다. 예약번호를 넣으면 지금 상태(유효 / 발권됨 / 취소·만료)를 철도사 실시간 목록과 대사해 돌려줍니다. 저희 DB 를 읽는 게 아니라 코레일·SR 에 직접 물어보므로, API 를 거치지 않은 변화(파트너사가 철도사 사이트·앱에서 직접 취소한 경우 등)도 잡힙니다.
회원 단위 조회는 bookings 를 쓰세요. 이 엔드포인트는 rsvIds 로만 조회합니다.
둘의 차이: bookings 는 저희 기록(빠르고 상세)이고, sync 는 철도사에 직접 물어 지금 상태를 확인합니다(파트너사가 철도사 사이트·앱에서 직접 취소한 경우까지 잡힙니다).
한 건만 볼 때도 rsvIds 에 하나만 담으면 됩니다. 발권된 건은 호차·좌석번호가 함께 옵니다.
승차권에는 예약번호가 없어(철도사가 주지 않습니다) 열차·일자·시각으로 맞춰야 하는데, 그 정보는
선점 때 저장해 둔 여정으로 저희가 채웁니다 — matchers 를 보내지 않아도 발권건을 인식합니다.
열차를 변경하는 등 저희 기록보다 최신 정보가 있을 때만 matchers 로 덮어쓰세요.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
rsvIds | string[] 필수 | 조회할 예약번호. 한 건만 볼 때도 하나만 담으면 됩니다. |
matchers | object[] 선택 | 발권 매칭 정확도↑. 열차를 변경하는 등 저희 기록보다 최신 정보가 있을 때만 덮어쓰세요. |
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
verifiedAll | boolean | 전 계정을 확인했는가 — false 면 응답에 없는 예약번호를 “취소됨”이 아니라 “미확정”으로 다뤄야 합니다. |
active | string[] | 아직 유효(미결제 포함). |
ticketed | object[] | 발권됨. 인원 전체는 seats[] 를 쓰세요 — carNo·seatNo 는 첫 좌석 하나입니다. |
ticketed[].seatNoEnd | string | 철도사가 채워 주지 않습니다 — seats 를 쓰세요. |
cancelled | string[] | 취소·만료. |
totalActive | number | 보내신 rsvIds 중 해당하는 건수입니다(= active·ticketed 배열의 길이). 계정에 있는 다른 예약은 세지 않습니다. |
verifiedAll 을 반드시 보세요.
예약은 그것을 잡은 계정에서만 보이므로 서비스는 등록된 계정을 모두 확인합니다.
일부 계정이 로그인·조회에 실패하면 verifiedAll: false 로 내려가고,
그때 사라진 예약번호를 cancelled 에 넣지 않습니다 — 못 본 계정이
들고 있을 수 있기 때문입니다. 이 경우 응답에 없는 예약번호는 "취소됨"이 아니라
"미확정" 으로 다뤄야 합니다. 취소로 단정하면 살아 있는 고객 예약을 잃습니다.
이 칸은 ok: true 든 ok: false 든 항상 옵니다(2026-09-14부터).
칸이 없으면 오류로 다뤄 주세요 — 참으로도 거짓으로도 읽지 마십시오.
그전에는 빠지는 경로가 하나 있었는데, 그것이 아래 rsvIds 를 안 실은 경우였습니다.
예약번호는 rsvIds 배열로 보내세요.
rsvId(단수)나 rsv_ids 가 아닙니다.
필드 이름이 다르면 저희는 아무것도 묻지 않은 것으로 읽습니다.
2026-09-14부터 그런 요청은 ok: false · code: "NO_RSV_IDS" 로 거절합니다.
그전에는 ok: true 와 빈 배열이 나가서, 받는 쪽에서는 "확인했는데 없다"와
구분할 수 없었습니다 — 발권된 표가 한나절 "확인되지 않음"으로 남은 사고가 있었습니다.
{
"rsvIds": ["…", "…"],
"matchers": [
{ "rsvId": "…", "service": "korail", "trainNo": "101",
"depDate": "20260810", "depTime": "0900" }
]
}{ "ok": true,
"verifiedAll": true,
"active": ["…"],
"ticketed": [{ "rsvId": "…",
"carNo": "3", "seatNo": "5A",
"seats": [ { "carNo": "3", "seatNo": "5A" },
{ "carNo": "3", "seatNo": "5B" } ],
"seatNoEnd": null,
"trainNo": "101", "depDate": "20260910", "depTime": "090000" }],
"cancelled": ["…"],
"totalActive": 3, "totalTickets": 1 }수수료 · 고객 청구액 #
콘솔(API 키 상세 > 수수료)에 등록한 발권 수수료율이 조회·선점·결제 응답에 함께 실립니다. 요율을 따로 확인해 곱하지 마세요 — 응답 값을 그대로 쓰면 됩니다. 코레일·SRT 공통 요율입니다.
// 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 은 적용한 취소수수료·구간·철도사 위약금·환불액을 남깁니다.
// 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 | 파트너가 고객 결제를 받습니다. 결제가 끝난 예약을 pay 의 authorizations 에 담아 보내면 저희가 철도사에 결제·발권합니다. 고객이 실제로 낸 수단은 payment 로 함께 보내 주세요. |
| RAILCONNECT | 저희가 고객 결제를 받습니다. methods 로 결제수단을 고르고 checkout 으로 결제 주소를 받아 고객을 보내면, 결제·확정·철도사 발권까지 저희가 처리합니다. pay 를 호출하지 마세요. |
2026-09-07 — RIDEUS 모드의 이름이 RAILCONNECT 로 바뀌었습니다.
같은 모드이고 동작은 하나도 달라지지 않았습니다.
고치실 것은 없습니다. 이 값은 저희 내부에서 키 설정을 가리키는 이름이라 API 응답에 실리지 않습니다 — 파트너 코드가 이 문자열을 다룰 일이 없습니다. 콘솔 화면과 이 문서의 표기만 바뀝니다.
RAILCONNECT 모드 흐름
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 는 사람이 읽는 설명이라
문구가 바뀔 수 있습니다. 코드의 의미는 바뀌지 않으며, 새 상황이 생기면 새 코드가 추가됩니다.
{ "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: true 와 code: "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 · lastError | reserve | 마지막 시도가 어디서 왜 멈췄는지. stage 가
all-accounts-failed 일 때 실제 사유는 여기 있습니다. |
attempts | reserve | 계정별 시도 기록 — accountIndex(몇 번째 계정) · stage · error.
계정 아이디는 담지 않습니다. |
priorAttempts | reserve | 성공 응답에 붙습니다. 앞선 계정이 실패한 뒤 다음 계정으로 성공했다는 뜻이며, 예약은 정상입니다. |
liveAllowed | cancel | false 면 라이브 게이트가 꺼져 있어 철도사에 취소 요청이 가지 않았습니다.
취소된 것으로 처리하지 마세요. |
cardsTried | pay | 등록된 카드를 몇 장까지 시도했는지(앞 카드가 거절될 때만 다음 장으로 넘어갑니다). |
철도사 계정 아이디는 응답에 담지 않습니다.
예약·결제는 등록된 철도사 계정으로 이뤄지지만, 그 로그인 아이디가 파트너 서버 로그나
브라우저에 남을 이유가 없습니다. 계정을 가릴 때는 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 의 같은
이벤트가 다시 오면 무시하면 됩니다.
웹훅 페이로드
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 — 경로 없이 도메인만).
등록하지 않은 키는 브라우저 호출을 받지 않습니다.
test 타입이거나 라이브 게이트가 꺼진 키여야 합니다. 실거래 키는 서버에만 두세요.OPTIONS /api/booking/availability → 204 (등록된 오리진) / 403
POST /api/booking/availability → 응답에 Access-Control-Allow-Origin 포함
Origin 헤더가 없는 서버 간 호출은 이 검사를 거치지 않습니다.프리플라이트가 허용하는 요청 헤더는 이 다섯입니다.
요청 서명을 켠 키는 X-Timestamp·X-Signature 가
필수이므로 여기 함께 들어 있어야 합니다.
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 실제로 서명 헤더 둘이 목록에서 빠져 있었고, 파트너가 오리진을 세 개
등록하고도 계속 실패했습니다. 지금은 고쳐졌습니다.)
직접 확인하실 수도 있습니다:
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).
회사명은 여러 파트너가 같을 수 있어 코드를 씁니다. 주소는 저희 발송 계정입니다. |
| 제목 | [파트너코드] 용산 → 서울 승차권이 발권되었습니다.취소는 … 승차권이 취소되었습니다. 다른 언어는 그 언어의 표기를 씁니다. |
| 로고 | 콘솔에 등록하신 파트너 로고가 메일 머리에 실립니다 (콘솔 › 파트너 상세 › 로고). 등록하지 않으면 로고 없이 나갑니다. |
| 언어 | reserve 의 lang → 키의
바우처 기본 언어 → 한국어 순입니다. |
| 내용 | 여정 · 좌석 · 인원 · 예매처 · 승차권 QR · 결제 금액. 취소 메일은 환불 내역을 앞세웁니다. |
파트너가 직접 발송하시는 경우, 바우처에 필요한 값은
ticket.issued 웹훅에 다 들어 있습니다 — 좌석·인원별 운임·
좌석마다 다른 승차권 QR 주소까지. 저희 메일을 받아 볼 수는 없으니
위 표는 참고용입니다.
서명 검증
secret 은 발급받은 API 키를 sha256 한 hex 문자열입니다. 이 secret 으로 본문 원문을
HMAC-SHA256 한 값이 X-Korail-Signature 와 같아야 합니다. 서비스는 키 원문을 보관하지
않으므로(해시만 저장) 양쪽이 같은 secret 을 각자 계산합니다.
secret = sha256(API_KEY).hexdigest()
expected = "sha256=" + hmac_sha256(secret, request_body).hexdigest()타임아웃은 5초이며 재시도하지 않습니다. 응답 지연이 예약·결제를 막지는 않지만, 수신 서버는 빠르게 2xx 를 반환하고 처리는 비동기로 넘기는 것을 권장합니다.
예약 세션 상태 #
각 예약은 세션으로 기록되며(콘솔 › 예약 세션에서 조회), 단계에 따라 상태가 바뀝니다. 아래가 기록되는 상태 전부이며, 각각 어떤 웹훅으로 나가는지 함께 적었습니다.
| 상태 | 대응 웹훅 | 의미 |
|---|---|---|
| created | — | dry-run(실거래 아님)으로 호출된 기록. 좌석은 잡히지 않았습니다. |
| reserved | booking.created |
좌석 선점(미결제) — 결제 대기. |
| paid | payment.paid |
철도사 결제 완료(발권은 아직). |
| ticketed | ticket.issued |
발권 완료 — 표가 실제로 나온 시점. |
| cancelled | booking.cancelled |
미결제 예약 취소 — 누군가 취소를 요청한 경우입니다. |
| expired | — | 결제기한이 지나 철도사가 좌석을 회수했습니다. 아무도 취소를 누르지 않았지만 그 예약은 없습니다. 웹훅은 보내지 않습니다. |
| refunded | refund.completed |
발권분 반환·환불 완료. |
| failed | — | 처리 실패. 응답으로 즉시 알 수 있으므로 웹훅을 따로 보내지 않습니다. |
created 와 booking.created 는 다른 것입니다.
세션의 created 는 dry-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 를 켜 드리겠습니다.