파트너 연동
Rail Connect 연동 가이드
파트너사가 코레일+ 기반 KTX 좌석 조회부터 예약·결제·발권·취소·상태동기화까지 연동하기 위한 개발 문서입니다. 모든 요청은 발급받은 API Key 로 인증하고, 함께 받으신 Secret Key 로 서명합니다(요청 서명).
최종 검토 · 2026-10-26 (KST) · 파트너 API v1 · 코레일+ 전환 반영
개요 · 예약 흐름 #
서비스는 파트너사의 주문 시스템을 대신해 코레일+ 기반 열차의 실제 예약·결제·발권을 수행합니다. 새 예약은 service: "korail"을 사용하며, 기존 SRT 예약의 취소·환불 경로는 유지합니다. 고객 결제를 누가 받느냐에 따라 연동이 갈립니다(결제 모드).
- 역 목록
stations— 출발·도착 선택에 쓸 역 카탈로그를 받습니다. 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요(철도사마다 이름이 다른 역이 있습니다). - 좌석 조회
availability— 구간·일자로 열차별 잔여/매진·요금을 받습니다. - 좌석 선점
reserve— 미결제 상태로 좌석을 잡고 예약번호(rsvId)를 받습니다. - 고객 결제 — Partner 파트너 화면에서 받습니다(우리 서비스는 파트너 주문을 읽지 않습니다) · RAILCONNECT checkout 으로 결제 주소를 받아 고객을 보냅니다.
- 결제 · 발권 —
Partner pay 에
authorizations를 담아 호출 · RAILCONNECT 결제 웹훅을 받아 저희가 발권합니다(pay호출 불필요). - 취소 / 환불
cancel— 미결제는 취소, 결제분은 환불로 자동 분기합니다. RAILCONNECT 모드는 고객 환불까지 함께 처리합니다. - 예매 내역 조회
bookings— 회원 식별자로 그 회원의 예매 목록·상세를 받습니다(파트너 사이트의 “내 예매 내역”). - 실시간 상태 대사
sync— 예약번호로 지금 상태(유효/발권/취소)와 발권된 좌석을 확인합니다. 저희 DB 가 아니라 철도사에 직접 물어보므로 저희를 거치지 않은 변화도 잡힙니다.
발권은 파트너사 계정으로 이뤄집니다. 고객의 코레일·SRT 계정이 아니라, 콘솔에 등록한 파트너사의 철도사 계정으로 예약·결제·발권합니다. 그래서 승차권은 그 계정의 예매 내역에 들어가고, 고객은 철도사 앱에서 이 예약을 볼 수 없습니다 — 고객에게는 저희가 보내는 승차권 바우처가 전달됩니다.
같은 이유로 철도사 사이트·앱에서 그 예약을 직접 건드릴 수 있는 쪽은 계정을 가진 파트너사입니다. 그렇게 취소하면 저희 기록과 어긋나므로 sync 로 대사해야 합니다.
금액 계산(정상운임·운임할인·결제운임·발권수수료·고객 청구액)은 수수료 · 청구액에, 취소 전 환불 예상액은 환불 예상액에 있습니다.
(Partner 모드) 고객 결제 확인 뒤 서버에서 pay를 호출하세요. 결제 기한은 reserve 응답을 기준으로 합니다. 폴러를 쓰더라도 PAY_UNKNOWN·PAY_UNRECORDED·CLAIM_CONFLICT는 자동 재결제 대상에서 제외하고, 예약별 결과를 먼저 확인하세요.
인증 · 라이브 게이트 #
모든 요청 헤더에 API 키를 담습니다. X-Api-Key 헤더도 허용됩니다.
Authorization: Bearer <API_KEY>| 개발용 ksd_ | 항상 dry-run — 실제 예약·결제가 되지 않습니다. 연동 테스트용. |
| 운영용 ksp_ | 콘솔의 파트너·API 키별 라이브 게이트가 켜진 동작만 실제 수행합니다. |
실거래 여부는 콘솔의 라이브 게이트가 정합니다. 운영용 키라도 파트너와 API 키 양쪽 설정이 모두 켜져 있어야 합니다. 요청 본문의 live는 사용하지 않으며, live:false로 보내도 실거래를 막을 수 없습니다.
| 동작 | 실행 조건과 꺼져 있을 때 |
|---|---|
reserve | 파트너·키의 예약 게이트 모두 ON이면 실예약. 그 외에는 mode:"dry"·stage:"dry-run"이며 rsvId가 없습니다. |
pay | 파트너·키의 결제 게이트 모두 ON일 때 승인된 예약만 결제·발권. 그 외에는 dry-run입니다. |
cancel | 예약 게이트가 필요합니다. OFF이면 ok:false·stage:"gate"·liveAllowed:false로 거절하며 dry-run 취소를 수행하지 않습니다. |
| 방치 홀드 스윕 | sweepStale:true와 예약 게이트 ON일 때 실행합니다. 결제 게이트와는 별개입니다. |
개발용 키는 예약·결제 게이트가 항상 OFF입니다. availability의 코레일+ 조회는 철도사 계정 없이 동작하지만 Rail Connect API 키와 요청 서명은 여전히 필요합니다.
요청 서명 · Secret Key #
API 키를 발급받으실 때 Secret Key 를 함께 드립니다. 모든 요청에 이 값으로 만든 서명을 실어 주세요 — 서명이 없거나 맞지 않으면 401 로 막힙니다.
| API Key ksp_/ksd_ | 신원 — 누구의 요청인지. Authorization 헤더로 보냅니다. |
| Secret Key kss_ | 증명 — 서명을 만드는 데만 씁니다. 절대 보내지 마세요. |
Secret Key 를 헤더에 담아 보내는 방식이 아닙니다. 그렇게 하면 API Key 와 유출 경로가 같아 하나가 새면 둘 다 샙니다. Secret Key 는 파트너 서버에서 서명에만 쓰고, Rail Connect에는 API 키·시각·서명만 보냅니다. 시크릿이나 인증 헤더를 애플리케이션 로그에 남기지 마세요.
헤더 세 개
Authorization: Bearer <API_KEY>
X-Timestamp: 1755100800
X-Signature: sha256=<hex>서명 대상(정본 문자열)
아래 넷을 줄바꿈(\n)으로 이어 붙인 문자열을 시크릿으로 HMAC-SHA256 한 뒤, 16진수로 적고 앞에 sha256= 를 붙입니다.
POST
/api/booking/availability
1755100800
<본문을 sha256 한 16진수>| 메서드 | 항상 POST (대문자). |
| 경로 | /api/booking/… — 쿼리스트링과 도메인은 뺍니다. |
| 시각 | 유닉스 초(밀리초 아님). X-Timestamp 와 같은 값이어야 합니다. |
| 본문 해시 | 실제로 보내는 바이트를 sha256 한 값. 본문이 없으면 빈 문자열의 sha256. |
본문은 한 번만 직렬화하세요. 서명할 때와 보낼 때 JSON.stringify·json.dumps 를
각각 부르면 공백이나 키 순서가 달라져 서명이 어긋납니다 — 서명 실패의 가장 흔한 원인입니다.
먼저 문자열/바이트를 만들고, 그것을 서명한 뒤 그대로 전송하세요.
조회부터 시작하는 실행 예제
환경변수 RAILCONNECT_API_KEY·RAILCONNECT_SECRET_KEY를 설정한 서버에서 실행하세요. Python 3.11+와 requests, Node.js 20+, PHP 8+와 cURL 기준입니다. 한국시간 내일의 KTX를 조회하며 예약·결제를 만들지 않습니다. 예매가 열리지 않은 날짜면 code를 확인해 날짜를 바꾸세요.
import hashlib, hmac, json, os, time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
API_KEY = os.environ["RAILCONNECT_API_KEY"]
SECRET = os.environ["RAILCONNECT_SECRET_KEY"]
BASE = "https://rail-connect.rideus.net"
PATH = "/api/booking/availability"
date = (datetime.now(ZoneInfo("Asia/Seoul")) + timedelta(days=1)).strftime("%Y%m%d")
payload = {"depName": "서울", "arrName": "부산", "date": date,
"time": "090000", "passengers": 1, "trainTypes": ["KTX"]}
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
ts = str(int(time.time()))
message = "\n".join(["POST", PATH, ts, hashlib.sha256(body).hexdigest()]).encode("utf-8")
signature = "sha256=" + hmac.new(SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
response = requests.post(BASE + PATH, data=body, headers={
"Authorization": "Bearer " + API_KEY,
"Content-Type": "application/json; charset=utf-8",
"X-Timestamp": ts,
"X-Signature": signature,
}, timeout=90)
data = response.json()
print(response.status_code, data) # ok/code도 확인하세요. 이 예제는 조회만 수행합니다.import crypto from "node:crypto";
const API_KEY = process.env.RAILCONNECT_API_KEY;
const SECRET = process.env.RAILCONNECT_SECRET_KEY;
if (!API_KEY || !SECRET) throw new Error("API Key와 Secret Key 환경변수가 필요합니다.");
const BASE = "https://rail-connect.rideus.net", PATH = "/api/booking/availability";
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const parts = new Intl.DateTimeFormat("en", { timeZone: "Asia/Seoul",
year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(tomorrow);
const part = type => parts.find(p => p.type === type).value;
const date = part("year") + part("month") + part("day");
const body = JSON.stringify({ depName: "서울", arrName: "부산", date,
time: "090000", passengers: 1, trainTypes: ["KTX"] });
const ts = String(Math.floor(Date.now() / 1000));
const hash = crypto.createHash("sha256").update(body, "utf8").digest("hex");
const sig = "sha256=" + crypto.createHmac("sha256", SECRET)
.update(["POST", PATH, ts, hash].join("\n"), "utf8").digest("hex");
const response = await fetch(BASE + PATH, { method: "POST", body,
signal: AbortSignal.timeout(90_000), headers: {
"Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json; charset=utf-8",
"X-Timestamp": ts, "X-Signature": sig,
} });
console.log(response.status, await response.json()); // ok/code 확인. 조회만 수행합니다.<?php
$apiKey = getenv("RAILCONNECT_API_KEY");
$secret = getenv("RAILCONNECT_SECRET_KEY");
if (!$apiKey || !$secret) throw new RuntimeException("API Key와 Secret Key 환경변수가 필요합니다.");
$base = "https://rail-connect.rideus.net";
$path = "/api/booking/availability";
$date = (new DateTimeImmutable("tomorrow", new DateTimeZone("Asia/Seoul")))->format("Ymd");
$body = json_encode(["depName" => "서울", "arrName" => "부산", "date" => $date,
"time" => "090000", "passengers" => 1, "trainTypes" => ["KTX"]],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$ts = (string) time();
$message = implode("\n", ["POST", $path, $ts, hash("sha256", $body)]);
$signature = "sha256=" . hash_hmac("sha256", $message, $secret);
$ch = curl_init($base . $path);
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $apiKey,
"Content-Type: application/json; charset=utf-8",
"X-Timestamp: " . $ts, "X-Signature: " . $signature]]);
$result = curl_exec($ch);
if ($result === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo $status . "\n" . $result; // JSON의 ok/code 확인. 조회만 수행합니다.서명은 재전송을 한 번만 허용하는 장치가 아닙니다. 현재 시각 기준 ±5분 안에서는 같은 요청이 다시 인증될 수 있습니다. 특히 reserve는 같은 본문을 다시 보내도 같은 예약번호를 돌려준다고 보장하지 않습니다. 주문별 동시 호출을 막고, 타임아웃은 예약 결과 확인 후 처리하세요.
실패했을 때
| 상태 | 응답 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이며 모든 열차 날짜·시각은 한국시간(KST, UTC+09:00)입니다. 철도사 운임·원화 금액은 원 단위 정수입니다. USD의chargeAmount·sdk.totalAmount·usd는 소수 금액이므로 통화별로 처리하세요. - 응답 공통 봉투
{ ok, stage, ... }. 실패 시{ ok:false, code, stage, error }— 분기는 오류 코드(code)로 하세요.
아래 요청·응답의 날짜·예약번호·금액은 구조 설명용 예시입니다. 실제 호출에는 조회 가능한 날짜와 API가 반환한 값을 넣으세요. // 주석과 … 생략 표시는 JSON 문법이 아니므로 실제 요청에서 제거해야 합니다. 위 실행 예제는 완전한 요청입니다.
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)이고
하루 한 번 갱신을 시도합니다. 아래 개수는 예시이며 실제 count를 사용하세요 — 역 이름을 직접 입력받지 말고 이 목록에서 고르게 하세요.
| 요청 | 결과 | 무엇 |
|---|---|---|
{} | 344 | 전체(무궁화·ITX 정차역 포함) |
{"trainTypes":["KTX"]} | 62 | KTX 가 서는 역 |
{"trainTypes":["SRT"]} | 0 | ⚠️ 2026-09-01 통합운행으로 값이 없어졌습니다 (아래 참고) |
{"carrier":"korail"} | 343 | 코레일이 취급하는 역 — KTX 와 다릅니다 |
{"carrier":"srt"} | 32 | 기존 SRT 코드가 남은 역 — 새 SRT 예약 지원을 뜻하지 않음 |
trainTypes 와 q·carrier 는 함께 쓸 수 있습니다(모두 만족하는 역만 옵니다).
trainTypes 안의 값끼리는 합집합입니다 — “둘 다 서는 역”이 아니라 “하나라도 서는 역”입니다.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
name | string | 역 이름. 호출할 때는 언제나 이 한국어 이름을 보냅니다. |
city | string | 소재 시·도. |
tagoId | string | 공공데이터 역 코드로, 다른 공공 API 와 맞출 때 쓰십시오. |
carriers | string[] | 카탈로그에 등록된 철도사 코드입니다. srt가 있어도 새 SRT 예약은 지원하지 않습니다. KTX 화면은 trainTypes로 거르세요. |
trainTypes | string[] | 그 역에 서는 열차 종류 — carriers 와 다릅니다(아래 경고). |
carrierNames | object | 참고용 — 파트너는 name 만 쓰면 됩니다(우리가 바꿔 넘깁니다). |
names | object | 화면 표시용 다국어(en·ja·zh-CN·zh-TW). 모르는 역은 빈 객체 {} 입니다. |
name 을 그대로 availability·reserve 에 넘기세요.
철도사마다 이름이 다른 역이 있는데(울산 ↔ 코레일·SR 은 울산(통도사),
김천구미 ↔ SR 은 김천(구미)) 저희가 바꿔서 넘깁니다.
수서·동탄·평택지제도 코레일+ 조회·예약 대상입니다. 현재 새 예매는 trainTypes:["KTX"]로 역을 고르고 service:"korail"로 예약하세요.
⚠️ 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 조회는 키 설정이 켜진 경우에만 추가하며 기본 OFF입니다. 코레일 계정 로그인은 하지 않지만 Rail Connect API 인증·서명은 필요합니다.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
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)도 조회·예약 인원으로 전달합니다. 현재 seatBasis.passengers와 유아 단위 요금(0)은 표시용 값이므로 무좌석·무료 예약 보장으로 해석하지 마세요. 유아를 동반하면 reserve.reservation.price로 최종 운임을 확인하세요.
응답 필드
| 필드 | 타입 | 설명 |
|---|---|---|
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인 일반실 기준입니다. |
paxUnitFares | object | 인원 구분별 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 입니다 — 일반실 값을 넣어 두면 특실 없는 무궁화호에 특실 요금이 뜨기 때문입니다.
인원 구분별 금액 — paxUnitFares
결제 전 화면에 “어른1 9,000원 / 어린이1 4,560원” 처럼 인원별로 세우실 때 쓰는 값입니다. 요율을 직접 곱하지 마세요 — 할인이 인원 구분마다 다르게 붙어 틀립니다(아래 참조).
"paxUnitFares": {
"exact": true,
"general": {
"adults": { "listFare": 7500, "fare": 7500, "discount": 0, "ticketFee": 1500, "total": 9000 },
"children": { "listFare": 3800, "fare": 3800, "discount": 0, "ticketFee": 760, "total": 4560 },
"seniors": { "listFare": 5300, "fare": 5300, "discount": 0, "ticketFee": 1060, "total": 6360 },
"toddlers": { "listFare": 0, "fare": 0, "discount": 0, "ticketFee": 0, "total": 0 }
},
"special": { … } // 특실이 없는 열차는 빈 객체
}모두 1인 금액입니다. 인원 수를 곱해 쓰시면 되고, 요금 표시에는 재사용할 수 있지만, 인원이 바뀌면 잔여석 판정을 위해 다시 조회해야 합니다 — 그래서 행이 아니라 단가로 드립니다. total 은 fare + ticketFee 이고, 그대로 화면의 인원별 소계가 됩니다.
할인 열차에서 generalFare × 요율 로 계산하면 크게 틀립니다. 할인은 어른에게만 붙고, 어린이·경로는 정가에서 자기 요율을 적용합니다.
어른 정가 44,600 → 31,200 (30% 할인)
어린이 22,300 ( = 정가의 50%, 할인 없음 )
✗ 31,200 × 0.5 = 15,600 ← 결제운임으로 계산하면 7,000원 가까이 모자랍니다이 규칙을 파트너마다 구현하지 않으셔도 되도록 저희가 계산해 드립니다.
exact: false 면 쓰지 마세요. 할인이 걸렸는데 저희가 정가를 되짚지 못한 경우입니다 — 그때는 인원별로 가를 근거가 없습니다. 1인 기준(customerTotal)으로 물러서 주세요. 틀린 금액을 드리느니 모른다고 말하는 편이 낫습니다.
발권수수료가 정액인 키(ticketFeeType: "fixed")는 ticketFee 가 0 입니다 — 정액은 예약 1건당이라 인원별로 나눌 수 없습니다. 합계에 한 번만 더해 주세요.
특실(special)의 어린이·경로 금액은 같은 요율로 계산한 값입니다 — 특실 추가요금에 대한 실측 확인은 아직 없습니다. 어긋나는 사례를 보시면 알려 주십시오.
고를 수 있는 날짜에는 끝이 있습니다 — 그런데 며칠 뒤인지는 고정이 아닙니다. 철도사가 기간을 나눠 열고, 명절 특별수송기간이 앞에 걸리면 그만큼 당겨집니다. (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": "off" },
"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 인원 필수 | passengers는 총 인원이며, paxBreakdown은 선택입니다. 구분을 보내지 않으면 성인으로 처리합니다. 구분을 보내면 그 합계가 예약 인원이 되므로 passengers와 맞추세요. 유아(toddlers)는 현재 코레일+ 예약 인원에 포함해 전달됩니다. 무좌석 동반으로 자동 제외하지 않습니다. 총 전달 인원은 1~9명이며 유아만 예약할 수 없습니다. |
service | enum 선택 | 새 예약은 "korail"을 보내세요. "srt" 새 예약은 SRT_DISCONTINUED로 거절합니다. 생략 시 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 | 환율을 확인할 수 있으면 언어와 무관하게 환산액이 함께 옵니다(통화 · 환율). |
reservation.price | number | 예약 총액(인원·등급 반영) — 수수료 계산 기준. |
reservation | string | 철도사가 반환한 결제 기한입니다. 빈 문자열·누락일 수 있으므로 10~20분을 임의로 더해 만들지 마세요. 그 전에 결제하지 않으면 좌석이 풀립니다 — Partner 모드는 이 시각까지 pay 를, RAILCONNECT 모드는 고객이 결제창을 마쳐야 합니다. |
reservation.seat_no | string number | 예약 인원 수와 좌석 정보입니다. seat_no는 null일 수 있으며 실제 탑승 좌석·QR은 발권 후 seats[]에서 확인하세요. |
reservation.rsv_id | string | 최상위 rsvId 와 같은 값입니다 — 예전 형태를 읽는 연동을 위해 남겨 둔 것이니, 새로 붙이신다면 최상위 rsvId 를 쓰세요. |
실패 종류에 따라 등록 계정을 순차 시도합니다. 예약 결과가 불명확하면 다른 계정으로 넘어가지 않습니다. rsvId를 보관해 이후 checkout/pay/cancel/sync/bookings에 그대로 넘기세요. dry-run이면 mode:"dry"·stage:"dry-run"으로 실제 좌석은 잡지 않습니다(rsvId도 없습니다).
타임아웃 · 예약 결과 불명
retryable:false가 오면 요청을 자동 재전송하지 마세요. 서버가 예약을 생성한 뒤 응답만 유실됐을 수 있습니다. 실패 응답에도 확인된 rsvId가 올 수 있으므로 보관하고 sync로 확인한 뒤 처리하세요. 번호를 받지 못했다면 담당자가 콘솔·철도사 내역을 확인해야 합니다.
같은 주문을 다른 계정으로 새로 예약하거나 고객 결제부터 다시 시작하지 마세요. 이 API는 동일 본문에 대한 예약 멱등성을 보장하지 않습니다.
{
"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",
"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": { "train_no": "101", "dep_time": "090000", "arr_time": "115000" },
"reservation": { "rsv_id": "320260844833693",
"train_no": "101", "price": 119600,
"seat_no": null, "seat_no_count": 2,
"buy_limit_date": "20260810", "buy_limit_time": "092000" } }결제수단 목록 #
checkout 의 method 에 넣을 값의 출처입니다.
결제수단 이름은 저희 콘솔에 등록된 값과 글자까지 같아야 하므로, 받아 적지 말고 이 목록에서 고르게 하세요.
키에 맞는 것만 옵니다. 테스트 키에는 테스트 결제수단만, 운영 키에는 운영 결제수단만 내려갑니다 — 화면에 세운 뒤 고객이 고른 다음에야 막히는 일이 없게 하려는 것입니다.
names 는 화면에 보여 줄 표기이고, checkout 에는 언제나 method 를 보냅니다.
이 엔드포인트는 RAILCONNECT 결제 모드 전용입니다. 파트너 결제 모드는 결제수단을 파트너 결제창에서 고르므로 호출할 일이 없습니다.
언어와 결제 통화
요청에 {"lang":"ko"}처럼 화면 언어를 보내세요. 각 수단의 currency는 그 수단을 선택했을 때의 청구 통화이며 overseas는 해외카드 여부입니다. USD라는 이유만으로 해외카드로 판단하지 마세요. 최종 금액은 선택한 method로 발급한 checkout.chargeAmount를 사용합니다.
{ "lang": "ko" }{ "ok": true, "count": 2, "env": "test", "methods": [
// method 를 그대로 checkout 에 넣으세요
{ "method": "국내카드", "names": { "en": "Credit card", "ja": "クレジットカード" }, "currency": "KRW", "overseas": false },
// 등록된 표기가 없으면 빈 객체 {} 입니다
{ "method": "해외카드", "names": {}, "currency": "USD", "overseas": true } ],
"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 도 봅니다). 환율은 결제 시점 값으로 고정되므로 단계 사이에 값이 조금 달라질 수 있습니다.
조회 응답
환율을 확인할 수 있으면 언어와 무관하게 열차마다 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 으로 거절합니다 — 오래된 환율로 긁으면 고객이 화면에서 본 금액과 다른 값이 카드에 찍힙니다.
환불은 원결제 통화와 결제 당시 환율을 기준으로 합니다. 현재 환율로 다시 계산하지 않습니다. 취소수수료를 차감한 환불액을 원결제 통화로 나누므로 항상 결제한 금액 그대로 전액이 돌아가는 것은 아닙니다. 왕복 한 편 취소도 해당 구간의 원화 비율만큼 계산하며 실제 결과는 refund.customerRefund를 확인하세요.
환율은 결제 시점 값으로 고정됩니다. 조회와 결제 사이에 환율이 바뀌면 청구액이 조금 달라질 수 있으니, 결제 직전 checkout 응답의 chargeAmount 를 최종 금액으로 보여 주세요.
결제창 발급 #
RAILCONNECT 결제모드 전용. 선점(reserve)이 끝난 예약에 대해 고객이 결제할 주소를 발급합니다. 그 주소로 고객을 보내면 결제·검증·철도사 발권은 저희가 처리합니다. 파트너 결제모드는 이 엔드포인트를 쓰지 않고 pay 로 진행합니다.
요청 필드 — 편도 또는 왕복
| 필드 | 타입 | 설명 |
|---|---|---|
rsvId | string 편도 필수 | reserve 로 잡은 예약번호. 왕복이면 legs 로 대신 보냅니다. |
legs | object[] 왕복 필수 | 왕복 — 가는 편·오는 편의 rsvId·fare. 한 결제에 담을 수 있는 구간은 2개까지입니다. |
method | string 필수 | methods 가 준 값 그대로 (글자까지 같아야 합니다). methodName 으로 보내도 됩니다. |
fare | number 필수 | 편도일 때 필수인 철도사 운임입니다. reserve.reservation.price를 보내며 customerTotal을 보내면 안 됩니다. 왕복이면 각 legs[].fare에 넣습니다. 발권수수료는 저희가 더합니다. reserve 응답의 금액과 다르면 400으로 거절합니다(왕복은 구간마다 대조). |
lang | enum 선택 | 화면 언어 — 통화가 갈립니다. |
orderRef | string 선택 | 귀사 주문식별자. 저희 결제창을 쓰시는 경우(RAILCONNECT 모드) 주문번호가 들어오는 자리는 여기뿐입니다 — 보내지 않으시면 저희 콘솔의 「파트너 주문번호」가 비어, 고객 문의·정산 대조 때 귀사 주문과 연결할 방법이 없습니다. 형식에 제약은 없고 그대로 저장해 보여 드리기만 합니다. 왕복이라도 결제는 한 건이므로 하나입니다. |
가는 편·오는 편을 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": "홍 길동", "buyerEmail": "gildong@example.com", "buyerPhone": "01012345678",
"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 } : {}),
...(sdk.buyerEmail ? { email: sdk.buyerEmail } : {}),
...(sdk.buyerPhone ? { phoneNumber: sdk.buyerPhone } : {}) },
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,
buyer_email: sdk.buyerEmail || undefined, buyer_tel: sdk.buyerPhone || 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": 40400, "ticketFee": 4040,
"customerTotal": 44440 } ],
"method": "국내카드",
"orderName": "(KTX) 편도 서울역 → 부산역 승차권",
"apiVersion": "v1",
"warning": null
}결제 · 발권 #
서비스는 파트너 주문을 읽지 않습니다. 고객 결제가 끝난 예약만 authorizations에 담아 전달해야 결제됩니다(승인 없는 예약은 결제하지 않음).
현재 코레일+ 경로는 결제와 발권을 함께 처리합니다. issueTicket는 응답의 서버 설정이며 요청으로 발권을 끄는 필드가 아닙니다. 결제만 하고 나중에 별도로 발권하는 호출은 지원하지 않습니다.
요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
authorizations | object[] 필수 | 고객 결제가 끝난 예약만 담습니다 — 승인 없는 예약은 결제하지 않습니다. |
authorizations[] | string 필수 | 예약번호와 귀사 주문식별자. 자체 PG 로 결제를 받으시는 경우(Partner 모드) 주문번호가 들어오는 자리는 여기뿐입니다 — checkout 을 부르지 않으시기 때문입니다. 발권에 실패한 건에도 그대로 남아 대조할 수 있습니다. |
authorizations[].paidAmount | number string 필수 | 고객 결제액의 원화 환산액입니다. paidAmount는 항상 KRW로 해석하며 currency:"USD"를 보내도 바뀌지 않습니다. USD 금액을 이 필드에 넣지 마세요. 실제 카드 금액·통화는 chargeAmount·chargeCurrency로 따로 보냅니다. 철도사 청구액이 확인 가능한 고객 원화 결제액보다 크면 중단합니다. |
authorizations[] | number string number 권장 | 고객 카드에 실제로 찍힌 금액·통화·적용환율입니다. 해외카드 결제는 원화 청구액과 통화부터 달라, 이 값이 없으면 카드전표와 대조할 수 없습니다. 콘솔 예매 상세의 '고객 실제 결제' 가 이 값입니다. chargeAmount × fxRate ≈ paidAmount 로 보내 주세요. |
authorizations[]{ method, approvalNo } | 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 | 무슨 일이 있었는지의 기계 판독용 코드. 정상 결제가 아닌 건에는 언제나 붙습니다(2026-09-29부터). reason 문구는 언제든 바뀔 수 있으니 이 값으로 판단하세요. |
results[].issued | bool | 그 예약에 유효한 표가 있는가. 자동 환불 여부를 이 값으로 정하시면 됩니다.false — 발권되지 않았습니다. 환불하셔도 표를 빼앗지 않습니다.true — 발권돼 있습니다. 환불하면 고객이 산 표를 빼앗습니다.키가 없음 — 저희도 모릅니다. 담당자 확인으로 넘겨 주세요. 모르는 것을 false 로 지어내면 고객의 표를 빼앗고, true 로 지어내면 낸 돈을 돌려드리지 못합니다. |
results[].settled | bool | true 면 더 시도할 것이 없는 확정 상태입니다 — 재시도하지 마세요. |
results[].seats | object[] | 인원 수만큼. 발권 직후 조회한 값이라 못 얻으면 빈 목록입니다. 좌석마다 carNo·seatNo 와 ticketNo(승차권번호)가 옵니다. |
seats[].ticketNo | string | 승차권번호 — 82273-0930-10207-54 형식입니다. 좌석마다 다릅니다(철도사가 인원별로 승차권을 따로 끊습니다). 승차권에 인쇄되는 값이라 고객 안내·창구 문의에 그대로 쓰실 수 있습니다.⚠️ 끝 두 자리는 반환(환불) 비밀번호입니다. 고객 본인에게 보여 주는 것은 정상이지만, 제3자에게 노출되지 않도록 다뤄 주세요. 2026년 10월 1일 이전 발권분에는 이 값이 없습니다 — 그 전에는 저희가 모으지 않았습니다. 키가 없을 수 있으니 존재 여부를 확인하고 쓰세요. |
results[].approvalNo | string | 카드 승인번호. SRT 는 제공하지 않아 빈 값입니다. |
results[].payLimitAt | string | dry-run·방치홀드 결과의 결제 기한입니다. string | null이며 기한을 받지 못하면 null입니다. 정상 결제 결과에는 이 키가 없을 수 있습니다. |
ticketFeeTotal | number | 금액이 확정된 건만 더한 값 — 건너뛴 건은 빠집니다. |
holdSweep | object | 방치 홀드 자동취소 결과. |
vouchersSent | number | 우리가 보낸 승차권 바우처 수. |
status:paid(결제·발권 완료) ·skipped(승인없음/한도초과/이미결제 등) ·failed·paid-unrecorded(표는 발권됐고 저희 이력 기록만 실패 — 재시도하지 마세요, 이중 결제가 됩니다) ·dry-run.- 응답 전체가
ok: true라도 예약별로 갈립니다.results에서rsvId로 귀사 예약을 찾아 그 건의status·code·issued를 보세요 — 최상위ok로 판단하면 실패한 건에도 바우처가 나갑니다(2026-09-28 실제로 그랬습니다). 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 로 돌려드리니
PAY_UNKNOWN은 자동 재결제를 중단하고 sync·콘솔에서 실제 상태를 확인한 뒤 담당자가 판단해야 합니다.
{
"carrier": "korail",
"rsvId": "…",
"authorizations": [
{ "rsvId": "…", "orderRef": "주문식별자", "paidAmount": 131560,
"chargeAmount": 131560, "chargeCurrency": "KRW",
"payment": { "method": "신한카드 5432-****-****-1234", "approvalNo": "16004215" } }
]
}{ "ok": true, "mode": "live", "issueTicket": true, "authorized": 1,
"results": [ { "rsvId": "…", "orderRef": "…", "carrier": "korail",
"status": "paid", "issued": true, "approvalNo": "…", "amount": 119600,
"seats": [ { "carNo": "3", "seatNo": "5A" },
{ "carNo": "3", "seatNo": "5B" } ],
"ticketFee": 11960, "customerTotal": 131560 } ],
"ticketFeeTotal": 11960, "customerTotal": 131560,
"carriers": [ … ], "accounts": [ … ],
"holdSweep": { "requested": false, … },
"vouchersSent": 1 }방치 홀드 자동취소 (코레일) #
결제창에서 이탈해 결제로 이어지지 못한 reserve 좌석을, 코레일 결제기한 전에 취소해 되돌립니다. pay 요청에 아래 필드를 추가하면 켜집니다(코레일 전용).
{
"authorizations": [ … ],
"sweepStale": true,
"heldRsvIds": ["…", "…"],
"sweepStaleMinutes": 10
}요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
sweepStale | boolean 필수 | 방치 홀드 자동취소 opt-in. |
heldRsvIds | string[] 필수 | 결제 진행 중이라 취소하면 안 되는 예약번호. |
sweepStaleMinutes | number 선택 | 방치 판정 나이, 기본 10분. |
필수 — heldRsvIds. 우리 서비스는 파트너 주문 상태를 알지 못합니다. 지금 결제 진행 중인 예약번호를 반드시 heldRsvIds에 담아 보내야 합니다 — 빠뜨리면 결제창에 머무는 고객의 좌석이 취소될 수 있습니다.
authorizations에도 heldRsvIds에도 없고 sweepStaleMinutes를 넘긴 홀드만 취소됩니다. 실행 조건은 sweepStale:true와 콘솔의 예약 라이브 게이트입니다. live:false로 실행을 막을 수 없으므로 보호 목록과 게이트를 먼저 확인하세요. 결과는 응답 results[]에 hold-cancelled / hold-kept / hold-dry-run으로 실립니다.
SRT 는 이 스윕을 돌리지 않습니다. sweepStale 을 보내도 SRT 예약은 대상에서 빠집니다 —
요청이 거부되지는 않고 그냥 지나갑니다.
기존 SRT 예약도 철도사의 구입기한이 지나면 좌석이 자동으로 풀립니다. 해제 시한은 고정된 분 수가 아니라 해당 예약의 철도사 결제기한을 기준으로 하세요.
결제 거절 통지 #
Partner 결제모드 전용입니다. 그 모드에서는 파트너가 자기 PG 로 결제를 받고 성공한 뒤에야 pay 를 부르므로, 결제가 거절되면 우리 쪽에는 아무 신호도 오지 않습니다. 이 호출로 알려 주시면 예약 세션에 「결제 거절」 한 줄이 남아, 대사와 고객 문의 때 원인을 바로 볼 수 있습니다.
{
"orderRef": "aphrs_mus3o4m5_309d2eb6c9",
"rsvId": "320261006352102",
"failedAt": "2026-10-03T16:57:48+09:00",
"reason": "F0004:PG사 결제요청에 실패하여 중단합니다.(imp_…) X000, Please contact your card issuer for assistance.(8373) 해외 카드 발급사 거래 거절"
}요청 필드
| 필드 | 타입 | 설명 |
|---|---|---|
rsvId | string 필수 | 거절된 결제가 붙어 있던 예약번호. 없으면 400 입니다 — 어느 예약인지 모르면 적을 자리가 없습니다. |
orderRef | string 권장 | 파트너 거래번호. 재전송을 가리는 열쇠입니다(아래 멱등 참조). 빠뜨리면 같은 통지가 두 줄로 쌓입니다. |
reason | string 권장 | PG 가 준 실패 사유 원문. 손대지 말고 그대로 보내 주세요 — PG 코드를 뽑는 일은 우리가 합니다. 500자를 넘으면 잘립니다. |
failedAt | string 선택 | 실제 거절 시각(ISO 8601). 통지가 늦게 와도 그 시각이 남습니다. 없으면 우리 수신 시각만 남습니다. 표기는 맞추지 않아도 됩니다 — UTC( …Z)·+09:00·그 밖의 시간대를 모두 받아 저희가 KST 로 옮겨 보관합니다. 시간대를 빼고 보내시면 KST 로 봅니다. |
응답
| 필드 | 타입 | 설명 |
|---|---|---|
sessionId | string | 적힌 예약 세션의 id. |
recorded | boolean | 이번 호출로 새로 적었는가. |
duplicate | boolean | 같은 orderRef 가 이미 적혀 있어 재전송으로 보고 넘긴 경우에만 true 입니다. |
이 호출은 좌석을 풀지 않습니다. 기록만 합니다. 좌석 반환은 지금처럼 cancel 에 expectUnpaid: true 를 실어 따로 불러 주세요.
두 일을 한 호출에 묶지 않은 이유가 있습니다. 결제 실패 화면은 카드를 바꿔 다시 시도하라고 좌석을 들고 있는데, 통지가 곧 해제가 되면 재시도할 좌석이 사라집니다. 재예약은 철도사의 중복 예약 차단(WRR800029)에 걸립니다. 언제 풀지는 파트너가 정하는 것이 맞습니다.
멱등 — 재전송은 한 줄로
같은 orderRef 가 이미 적혀 있으면 새로 적지 않고 duplicate: true 와 기존 sessionId 를 돌려줍니다. 재전송을 마음껏 해도 안전합니다.
고객이 카드를 바꿔 다시 결제했다가 또 거절되면 그것은 다른 실패입니다 — 파트너 거래번호가 새로 생기므로 별개의 줄로 적힙니다. 거래번호를 보내지 않으면 재전송을 가릴 수 없어 그대로 쌓입니다.
RAILCONNECT 결제모드 키로는 받지 않습니다(400). 그 모드는 결제창이 우리 페이지라 거절을 우리가 이미 기록합니다 — 통지까지 받으면 같은 사실이 두 벌이 됩니다.
예매 조회 상태는 바뀌지 않습니다
통지는 예약 세션에만 남습니다. bookings 조회의 status 는 그대로 reserved 이고, status=payment_failed 로 거를 수도 없습니다.
일부러 그렇게 두었습니다. 결제가 거절된 좌석은 아직 재시도 중일 수 있습니다 — 카드를 바꿔 다시 결제하려는 고객의 예약이 조회에서 사라지면 안 됩니다. 좌석이 실제로 없어지는 것은 cancel 을 부르셨을 때(cancelled)와 철도사 결제기한이 지났을 때(expired)뿐이고, 조회 상태는 그 둘만 따릅니다.
인증·서명은 다른 엔드포인트와 같습니다(인증 · 요청 서명). 실패해도 재시도만 부르므로, 기록이 안 된 경우에도 200 에 recorded: false 로 답합니다.
취소 · 환불 #
미결제 예약은 취소, 이미 결제·발권된 예약은 환불로 자동 분기합니다. 다인원 예약은 전 인원 티켓을 함께 환불합니다.
자동 해제 경로에서는 expectUnpaid: true 를 함께 보내세요.
결제창 이탈·방치처럼 사람이 아니라 코드가 부르는 취소라면 이 플래그를 실어 주십시오. 그 건이 실은 결제돼 있었을 때 발권된 표를 물리고 고객 돈을 돌려주는 사고를 막습니다.
- 미결제 → 지금처럼 취소합니다.
- 이미 결제됨 → 취소하지 않고 거부합니다(
code: "ALREADY_PAID"·stage: "expect-unpaid"). 승인번호와 결제시각을 함께 드리니 대조하시면 됩니다. - 이미 취소·환불된 건은 플래그와 무관하게
ok: true·alreadyCancelled: true입니다 — 재시도를 멈추셔도 됩니다.
고객 요청 취소나 운영자 환불처럼 환불이 목적인 호출에는 넣지 마십시오. 그때는 환불이 정상 동작입니다.
열차가 출발한 뒤에는 취소할 수 없습니다.(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는 사용하지 않으며 게이트 OFF는 dry-run이 아니라 거절입니다.
이미 철도사에서 반환된 예약
파트너사가 철도사 사이트·앱에서 먼저 반환한 뒤 이 API 로 취소를 부르면, 물릴 표가 없습니다. 그때는 오류가 아니라 기록을 맞추고 고객 환불까지 처리한 결과가 옵니다.
{ "ok": true, "stage": "refunded", "rsvId": "…",
"alreadyCancelledAtCarrier": true, // 우리가 물린 게 아니라 이미 반환돼 있었다
"refund": { "applied": { "carrierFee": null, … } } }환불액 규칙은 평소 취소와 같습니다. 다만 철도사 위약금(carrierFee)은 null 입니다 —
그쪽 화면에서 반환돼 얼마를 뗐는지 저희가 알 수 없습니다.
철도사 계정을 하나라도 확인하지 못하면 이 처리를 하지 않습니다. 대신 RESERVATION_NOT_FOUND
가 돌아옵니다 — “로그인 실패”를 “표가 없다”로 읽으면 살아 있는 표를 환불하게 되기 때문입니다.
이 코드를 받으면 재시도하지 말고 콘솔에서 확인하세요.
{ "rsvId": "…", "service": "korail" }
{ "rsvId": "…", "expectUnpaid": 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, 기본 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 × 적용 요율, 10원 단위 올림. 요율은 키 설정 또는 기본 규정에 따라 달라지며 예시의 5%가 고정값은 아닙니다. 요율을 매긴 기준은 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 로 처리하지 않습니다. 저희 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 에 하나만 담으면 됩니다. 발권된 건은 호차·좌석번호가 함께 옵니다.
코레일+ 승차권은 예약번호(PNR)를 우선 대조하므로 같은 열차를 두 번 예약해도 서로의 좌석을 합치지 않습니다. 예약번호가 없는 호환 응답의 보조 매칭 정보는
선점 때 저장해 둔 여정으로 저희가 채웁니다 — 일반적인 조회에는 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": 1, "totalTickets": 1 }수수료 · 고객 청구액 #
콘솔(API 키 상세 > 수수료)에 등록한 발권 수수료가 조회·선점·결제 응답에 함께 실립니다. 요율을 따로 확인해 곱하지 마세요 — 응답의 ticketFee·customerTotal 을 그대로 쓰면 됩니다. 코레일·SRT 공통입니다.
받는 방식은 둘 중 하나입니다(2026-09-29부터). 응답의 ticketFeeType 으로 어느 쪽인지 아실 수 있습니다.
rate | 비율 — 결제운임의 몇 %입니다. ticketFeeRate 에 그 요율이 옵니다. |
fixed | 정액 — 예약 1건당 같은 금액입니다(3인이 한 예약이면 1회, 왕복이면 예약이 둘이므로 2회). 이때 ticketFeeRate 는 0 입니다 — 요율이 뜻을 갖지 않기 때문입니다. 0 을 '수수료 없음'으로 읽지 마세요. ticketFee 가 실제 금액입니다. |
// 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 를 호출하지 마세요. |
주문번호(orderRef)를 보내시는 자리도 모드에 따라 갈립니다. 부르지 않는 쪽으로는 보낼 수 없으니, 쓰시는 모드의 자리에 실어 주세요. 이 값이 비면 저희 콘솔에서 귀사 주문과 연결할 수 없어, 고객 문의와 정산 대조를 철도사 예약번호만으로 해야 합니다.
| Partner | pay 의 authorizations[].orderRef — 예약마다 하나씩. 발권에 실패한 건에도 남습니다. |
| RAILCONNECT | checkout 본문의 orderRef — 결제 한 건에 하나(왕복이라도 하나). |
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 | 불가 | 저희가 철도사에 붙지 못하고 있습니다. retryable:false이면 폴러의 반복 호출을 중단하세요. 로그인 성공 후 조회만 제한될 수도 있으며, 서버는 차단 시 예약 전 재조회·다른 계정 순회를 중단합니다. error 문구는 고객에게 그대로 보여 주셔도 됩니다 — 내부 사정은 담지 않았습니다. 저희에게 알려 주시면 조치합니다. |
CARRIER_UNAVAILABLE | 가능 | 철도사가 응답하지 않거나 전 계정 실패 |
SEARCH_FAILED | 가능 | 열차 조회 실패 |
DATE_NOT_OPEN | 고쳐야 함 | 그 날짜는 아직 예매가 열리지 않았습니다 |
DATE_SPECIAL_PERIOD | 고쳐야 함 | 명절 특별수송기간 — 예매일이 따로 지정됩니다. 안내문이 error 에 그대로 옵니다 |
RESERVE_FAILED | 가능 | 좌석 선점 실패 |
| 결제 · 금액 | ||
AMOUNT_EXCEEDED | 고쳐야 함 | 건당 상한 초과 |
AMOUNT_MISMATCH | 고쳐야 함 | 철도사 청구액이 고객 결제액보다 큼 |
ALREADY_PAID | 고쳐야 함 | 이미 결제된 예약 |
PAY_FAILED | 원인 확인 | 카드 승인 실패. 등록된 카드가 모두 거절된 경우도 여기입니다 — 재시도해도 같으니 저희에게 알려 주세요. |
CARDS_DECLINED | 가능 | 등록된 카드가 모두 거절됐습니다 — 긁히지 않았습니다. issued: false 로 오므로 안전하게 환불하셔도 됩니다. 반복되면 저희에게 알려 주세요(카드 한도 문제일 수 있습니다). |
PAY_UNKNOWN | 사람 확인 | 통신 오류 등으로 결제됐는지 알 수 없습니다. 재시도하면 이중 결제, 환불만 하면 저희 손실이 될 수 있어 issued 를 싣지 않습니다 — 저희에게 알려 주세요. |
PAY_UNRECORDED | 고쳐야 함 | 표는 발권됐습니다(issued: true). 실패한 것은 저희 이력 기록뿐입니다. 재시도하지 마세요 — 이중 결제가 됩니다. 이 응답에는 좌석·QR 이 없으니(seatsUnavailable) 저희에게 알려 주시면 기록을 고친 뒤 전달드립니다. |
CLAIM_CONFLICT | 잠시 뒤 | 같은 예약을 다른 실행이 처리 중입니다. 그 실행이 끝났는지 진행 중인지 알 수 없어 issued 를 싣지 않습니다 — 잠시 뒤 상태를 다시 확인하세요. |
LEDGER_UNREADABLE | 잠시 뒤 | 결제 이력을 읽지 못해 판단을 보류했습니다. 잠시 뒤 다시 시도하세요. |
| 환불 · 기타 | ||
REFUND_FAILED | 가능 | 환불 실패 |
INTERNAL | 가능 | 서버 내부 오류 |
가능은 같은 요청을 그대로 다시 보내도 되는 경우입니다(철도사 일시 장애 등). 고쳐야 함은 요청이나 설정을 바꿔야 하므로 재시도해도 같은 결과입니다.
pay 는 예약별로 결과가 갈립니다.
응답 전체가 ok: true 여도 개별 건은 실패일 수 있습니다 —
results[].code 를 확인하세요(정상 결제 건에는 code 가 없습니다).진행 단계(stage)
분기는 code 로 하세요. stage 는 어디까지 갔다가
멈췄는지를 알려 주는 값입니다 — 같은 INTERNAL 이어도 철도사 로그인에서
막힌 것과 좌석을 잡다 막힌 것은 대응이 다릅니다. 문의하실 때 이 값을 함께 주시면
저희가 로그를 바로 찾습니다.
| stage | 누구 쪽 문제 | 무슨 뜻인가 |
|---|---|---|
| 여기까지 왔으면 성공 | ||
reserved | — | 좌석 선점 완료(reserve) |
checkout | — | 결제창을 열 준비 완료(checkout) |
cancelled · refunded | — | 취소·환불 완료(cancel) |
already-released | — | 결제되지 않아 이미 풀린 좌석 선점입니다(cancel). 결제기한이 지나면 철도사가 좌석을 거둬 가는데, 그 뒤에 취소를 부르면 여기로 옵니다. alreadyCancelled: true 와 code: "HOLD_ALREADY_RELEASED" 가 함께 옵니다 — 이번 호출로 새로 처리한 것은 없으니 재시도하지 마세요. |
expect-unpaid | 409 | expectUnpaid: true 로 부른 취소인데 이미 결제·발권된 예약입니다(cancel). code: "ALREADY_PAID" 와 승인번호·결제시각이 함께 옵니다 — 취소하지 않았습니다. 환불이 목적이라면 플래그 없이 다시 부르세요. |
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 |
미결제 예약 취소 — 누군가 취소를 요청한 경우입니다. |
| already_settled | — | 이번 호출로 새로 처리한 것이 없습니다. 이미 풀린 좌석 선점이거나 이미 환불된 표를 다시 취소하려 한 경우입니다(응답의 alreadyCancelled). 취소가 두 번 일어난 것이 아니므로 집계에 넣지 마세요. 무엇이 이미 끝나 있었는지는 같은 줄의 단계가 말합니다 — 예약 선점이면 풀린 좌석, 발권 완료면 환불된 표입니다. 웹훅은 보내지 않습니다. |
| expired | — | 결제기한이 지나 철도사가 좌석을 회수했습니다. 아무도 취소를 누르지 않았지만 그 예약은 없습니다. 웹훅은 보내지 않습니다. |
| refunded | refund.completed |
발권분 반환·환불 완료. |
| payment_failed | — | 파트너 결제창에서 PG 가 결제를 거절한 것(Partner 결제모드).
결제 거절 통지 로 알려 주신 건입니다. 아래
failed 와 다른 상태입니다 — 그쪽은 우리 API 호출 자체가
실패한 것이라, 같은 값이면 연동 장애와 고객 카드 거절을 가릴 수 없습니다. |
| failed | — | 처리 실패. 응답으로 즉시 알 수 있으므로 웹훅을 따로 보내지 않습니다. |
created 와 booking.created 는 다른 것입니다.
세션의 created 는 dry-run 호출 기록이고, 웹훅
booking.created 는 좌석이 실제로 잡힌 시점(세션 reserved)입니다.
나란히 놓고 보면 반드시 헷갈리는 지점이라 미리 밝혀 둡니다.
기한이 지난 선점은 expired 로 바뀝니다(2026-08-22부터).
저희가 주기적으로 철도사에 확인한 뒤 기록하므로, 기한 직후 잠깐은
아직 reserved 로 보일 수 있습니다. 그 사이 확실히 알아야 하면
sync 로 물어보세요.
⚠️ 이 작업은 좌석을 풀지 않습니다 — 기록만 합니다.
좌석을 거둬 가는 것은 철도사의 결제기한이고, 저희는 30분마다
철도사에 물어 이미 사라진 것을 expired 로 적습니다.
해제 시한을 저희 주기에 맞춰 계산하지 마세요 — 기준은 철도사 기한입니다.
그보다 먼저 풀고 싶으시면 cancel 을
expectUnpaid: true 로 부르시면 되고, 그만큼 재판매가 빨라집니다.
(2026-10-07 에 한 파트너가 저희 주기를 해제 시한으로 오해해, 어느 쪽이 먼저
도는지가 운에 달린 상태로 두고 있었습니다.)
bookings 조회에도 같은 값이 실립니다 —
status: "expired". 결제 대기 목록을 그리신다면
reserved 만 세시고 expired 는 빼 주세요.
예전에는 둘이 구분되지 않아 결제 대기가 계속 쌓이기만 했습니다.
status=reserved 로 거르면 만료된 건은 이제 빠집니다.
결제 전에 취소한 선점은 cancelled 입니다(2026-09-14부터).
cancel 을 결제 전에 부르신 건이 여기 해당합니다.
status=reserved 로 거르면 이것도 함께 빠지고,
status=cancelled 로 따로 부르실 수 있습니다.
그전까지는 취소하신 선점이 계속 reserved 로 보였습니다 —
결제 대기 목록을 reserved 로 그리고 계셨다면 그 수가 줄어듭니다.
만료 웹훅은 보내지 않습니다. 새 이벤트를 흘리면
받을 준비가 안 된 쪽에 오류로 쌓입니다 — 필요하시면 말씀해 주세요, 그때
booking.expired 를 켜 드리겠습니다.