시작하기
가맹점 조회 API
페이소프트로 정기결제(구독)를 운영하는 가맹점이 구독 계약별 월 결제 상태(완료·미납), 결제결과, 고객의 자동이체 등록 상태를 우리 회사 프로그램에서 바로 조회하는 API입니다. 우리 회사 회원번호(고객키)로 찾을 수 있습니다.
쉽게 말하면 — 페이소프트 관리자 화면에 들어가 엑셀로 내려받던 "이번 달 누가 냈고 누가 안 냈나"를, 우리 회사 서버가 주소 하나를 호출해 JSON 으로 받아 가는 것입니다. 받은 값을 우리 회원 DB 에 저장해 두면 회원 화면·미납 안내·정산에 쓸 수 있습니다.
조회만 제공합니다. 결제 요청·취소·환불, 회원 등록·변경은 이 API로 할 수 없습니다. 이 API 를 아무리 호출해도 고객에게서 돈이 빠져나가지 않습니다.
무엇을 조회할 수 있나
| 알고 싶은 것 | API | 한 줄이 뜻하는 것 |
|---|---|---|
| 이번 달 냈나, 안 냈나 가장 많이 씀 | GET /v1/billings | 계약 1개 × 한 달 |
| 이번 달 합계(청구·결제·미납 금액) | GET /v1/billings/summary | 한 달 전체 합계 |
| 출금 시도 한 건씩 (재출금 포함) | GET /v1/payments | 출금 시도 1번 |
| 날짜별 결제 건수·금액 | GET /v1/payments/summary | 출금일 하루 |
| 구독 계약 상태(정상·일시 중지·해지) | GET /v1/contracts | 계약 1개 |
| 고객 정보·자동이체 등록 상태 | GET /v1/customers | 고객 1명 |
| 결제수단별 등록 상태 | GET /v1/members | 결제수단 1개 |
- 조회 범위는 정기결제(자동이체·정기 카드) 원장입니다. 다른 원장(카드 즉시결제 등)의 거래는 이 API에서 보이지 않습니다.
- 회원 이름·연락처·계좌번호·카드번호는 응답에 포함하지 않습니다.
- 가맹점은 페이소프트가 승인할 때 연결한 이용기관의 데이터만 조회할 수 있습니다.
5분 만에 첫 호출
처음이라면 이 순서대로 따라 하십시오. 테스트 키는 가상 데이터만 돌려주므로 마음껏 호출해도 됩니다.
- 테스트 키 준비 — 콘솔 [API 키]에서 환경 시험으로 키를 발급합니다.
이때 호출할 컴퓨터(서버)의 공인 IP를 등록합니다. 내 PC 에서 시험한다면 내 PC 의 공인 IP 를 넣습니다.
발급 화면에 보이는
psk_test_…값을 복사해 둡니다(다시 볼 수 없습니다). - 키를 환경변수에 넣기 — 소스코드에 직접 쓰지 않고 환경변수로 둡니다.
export PAYSOFT_API_KEY="psk_test_여기에_발급받은_키"
$env:PAYSOFT_API_KEY = "psk_test_여기에_발급받은_키"
- 호출하기 — 2026년 10월 계약별 결제 상태를 조회합니다.
curl "https://api.paysoft.co.kr/v1/billings?month=202610" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
Invoke-RestMethod -Uri "https://api.paysoft.co.kr/v1/billings?month=202610" ` -Headers @{ Authorization = "Bearer $env:PAYSOFT_API_KEY" } | ConvertTo-Json -Depth 5import os, requests # pip install requests r = requests.get("https://api.paysoft.co.kr/v1/billings", params={"month": "202610"}, headers={"Authorization": f"Bearer {os.environ['PAYSOFT_API_KEY']}"}, timeout=30) print(r.status_code) print(r.json())// Node.js 18 이상 (fetch 내장) const r = await fetch("https://api.paysoft.co.kr/v1/billings?month=202610", { headers: { Authorization: `Bearer ${process.env.PAYSOFT_API_KEY}` }, }); console.log(r.status, await r.json()); - 결과 확인 —
200과 함께 계약 6개(1001~1006)의 상태가 옵니다.1002는paid(재출금으로 결제 완료),1005는unpaid(미납)입니다. 어떤 계약이 어떤 상태로 오는지는 테스트 환경에 정리돼 있습니다.
| 이렇게 나오면 | 원인과 해결 |
|---|---|
401 INVALID_KEY | Authorization: Bearer 뒤에 키가 빠졌거나 잘못 복사했습니다. 앞뒤 공백·줄바꿈을 확인하십시오. |
403 IP_NOT_ALLOWED | 키에 등록한 IP 와 실제 호출 IP 가 다릅니다. 회사망·VPN 은 공인 IP 가 다를 수 있습니다. 콘솔 [접속 기록]·[사용 현황]에서 들어온 IP 를 확인하십시오. |
422 INVALID_PARAM | month 를 2026-10 처럼 넣었습니다. 202610(YYYYMM)으로 넣으십시오. |
| 연결이 안 됨·시간 초과 | 회사 방화벽이 api.paysoft.co.kr 로 나가는 HTTPS(443)를 막고 있지 않은지 확인하십시오. |
가입부터 운영까지
- 가입 신청 — 가입 신청 후 받은 메일의 링크로 이메일 인증을 마칩니다.
- 페이소프트 승인 — 페이소프트가 사업자 정보와 이용기관을 확인해 승인합니다. 승인 결과는 콘솔 첫 화면에 나타납니다.
- 테스트 키 발급 — 콘솔에서 환경을 시험으로 골라 키를 발급합니다. 호출할 서버의 공인 IP 를 함께 등록합니다.
- 가상 데이터로 개발 — 테스트 키는 실제 데이터 대신 가상 데이터를 돌려줍니다. 미납·재출금·확인 필요 같은 경우가 모두 들어 있어 화면과 예외 처리를 미리 만들 수 있습니다.
- 고객키 설정 — 우리 회원번호로 찾으려면 고객키를 지정합니다(운영 데이터에만 필요, 테스트는 미리 지정돼 있음).
- 운영 키로 전환 — 운영 키(
psk_live_)를 발급해 서버 환경변수만 바꿉니다. 주소와 응답 형식은 같습니다.
인증 (API 키)
모든 요청의 Authorization 머리글(헤더)에 API 키를 Bearer 방식으로 넣습니다. 별도의 로그인·토큰 발급 단계는 없습니다.
Authorization: Bearer psk_test_xxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
| 키 앞부분 | 환경 | 돌려주는 데이터 |
|---|---|---|
psk_test_… | 시험 | 정해진 규칙의 가상 데이터. 실제 결제 데이터에 접근하지 않습니다. |
psk_live_… | 운영 | 연결된 이용기관의 실제 데이터. |
키가 확인되면 아래 순서로 검사합니다. 앞 단계에서 걸리면 뒤 단계는 보지 않습니다.
- 키가 맞는가
401 INVALID_KEY - 허용 IP 인가
403 IP_NOT_ALLOWED - 가맹점이 이용 중인가
403 MERCHANT_INACTIVE - 이 API 를 쓸 권한(범위)이 있나
403 SCOPE_DENIED - 호출 한도 안인가
429 RATE_LIMITED
| 권한(범위) | 쓸 수 있는 API |
|---|---|
payments:read 결제결과 조회 | /billings · /billings/summary · /payments · /payments/summary · /payments/{id} |
members:read 회원 조회 | /contracts · /customers · /extra-fields · /members |
API 키는 발급 화면에서 한 번만 보입니다. 페이소프트도 다시 보여 줄 수 없습니다. 키는 서버에만 두고 브라우저·앱·저장소에 넣지 마십시오.
키마다 등록한 허용 IP에서 온 요청만 받습니다(최대 10개, CIDR 은 /24 까지). 그래서 브라우저(자바스크립트)나 모바일 앱에서 직접 호출할 수 없습니다. 반드시 우리 회사 서버에서 호출하고, 화면에는 서버가 저장한 값을 보여 주십시오.
테스트 환경 (가상 데이터)
테스트 키(psk_test_)로 호출하면 실제 데이터 대신 정해진 규칙의 가상 데이터를 돌려줍니다. 주소·요청·응답 형식은 운영과 같습니다. 어느 달을 조회해도 같은 시나리오가 나옵니다.
| 계약 | 고객 (고객키) | 결제수단·금액 | 매달 일어나는 일 | 월 결제 상태 |
|---|---|---|---|---|
1001 | C0001 (MEM-1) | CMS 20,000원 | 10일 출금 성공 | paid 결제 완료 |
1002 | C0002 (MEM-2) | CMS 30,000원 | 10일 잔액부족 실패 → 15일 재출금 성공 | paid 결제 완료 |
1003 | C0003 (MEM-3) | CMS 10,000원 | 10일 실패로 기록됐지만 응답코드는 정상 — 기록이 어긋남 | unknown 확인 필요 |
1004 | C0004 (MEM-4) | CMS 20,000원 | 출금 전 청구 취소 | canceled 청구 취소 |
1005 | C0005 (MEM-5) | CMS 30,000원 | 10일·15일 모두 잔액부족 실패 | unpaid 미납 |
1006 | C0001 (MEM-1) | CARD 30,000원 | 28일 출금 대기 | in_progress 결제 진행 중 |
| 항목 | 가상 데이터 규칙 |
|---|---|
| 고객 | C0001 ~ C0005. 고객키는 추가 항목 1번 = MEM-1 ~ MEM-5. C0001 은 계약이 2개(1001·1006)라 "한 고객에 계약 여러 개"를 시험할 수 있습니다. |
| 계약 상태 | 1003 일시 중지, 1004 중도 해지, 나머지 정상 |
| 거래번호 | TEST<출금일 YYYYMMDD><계약번호 끝 3자리> 예: TEST20261010005 (10월 10일, 계약 1005) |
| 추가 항목 | 1번 "회원번호"(필수), 2번 "회원 등급"(선택값 일반·VIP) |
| 이용기관 | TEST |
요청·응답 규칙
모든 API 는 아래 기본 주소 뒤에 경로를 붙여 GET 으로 호출합니다. API 는 전용 도메인 api.paysoft.co.kr 에 있고,
콘솔·개발자센터(console.paysoft.co.kr)와 주소가 다릅니다. 테스트 키와 운영 키 모두 같은 주소를 씁니다.
성공 응답은 언제나 같은 모양의 봉투(envelope)에 담겨 옵니다. 실제 내용은 data 안에 있습니다.
{
"as_of": "2026-10-31T09:00:00+09:00", // 원천을 조회한 시각
"request_id": "3f2a9c1e-…", // 문의할 때 알려 줄 번호
"data": [ … ], // 목록이면 배열, 한 건이면 객체
"next_cursor": null // 목록일 때만. null 이면 마지막 쪽
}| 필드 | 타입 | 설명 |
|---|---|---|
| as_of | string | 원천을 조회한 시각(KST, ISO 8601). 모든 성공 응답에 들어 있습니다. 결제 상태는 시간이 지나며 바뀌므로 이 값과 함께 저장하십시오. |
| request_id | string | 요청마다 붙는 번호입니다. 응답 머리글 X-Request-Id 와 같습니다. 문의할 때 알려 주십시오. |
| data | array · object | 목록 API 는 객체의 배열, 한 건 API 는 객체 하나입니다. |
| next_cursor | stringnull 가능 | 목록 API 에만 있습니다. 다음 쪽 위치입니다. 목록 넘기기를 보십시오. |
| 규칙 | 내용 |
|---|---|
| 형식 | 응답은 UTF-8 JSON 입니다. |
| 날짜·시각 | 날짜는 YYYY-MM-DD, 청구월은 YYYYMM, 시각은 ISO 8601(2026-10-10T18:00:00)입니다. |
| 금액 | 원 단위 정수입니다. 소수점·통화 기호가 없습니다. |
| null | 값이 없으면 필드를 빼지 않고 null 로 보냅니다. 표의 null 가능 표시를 확인하십시오. |
| 상태 이름 | Billing·Contract·Customer 의 상태에는 한글 이름(status_name)이 함께 옵니다. 화면에 그대로 보여 줘도 됩니다. |
| 캐시 | 모든 응답은 Cache-Control: no-store 입니다. 결제 상태를 캐시해 두고 판단하지 마십시오. |
| 새 필드 | 응답에 필드가 늘어나도 버전을 바꾸지 않습니다. 모르는 필드는 무시하도록 만드십시오. |
목록 넘기기 (cursor)
목록 API 는 한 번에 최대 100건(size)을 돌려줍니다. 더 있으면 next_cursor 에 값이 들어 있습니다.
다음 요청에 같은 조건 + cursor=그 값을 넣어 다음 쪽을 받고, next_cursor 가 null 이 될 때까지 반복합니다.
GET /v1/billings?month=202610→next_cursor: "WyJUR…"GET /v1/billings?month=202610&cursor=WyJUR…→next_cursor: "WyJUR…"- … →
next_cursor: null이면 끝
- 커서 값은 해석하거나 만들지 말고 받은 그대로 넣습니다.
- 정렬이 고정돼 있어 쪽을 넘기는 동안 건이 빠지거나 겹치지 않습니다.
status로 거르면 한 쪽이size보다 적거나 비어 있어도 다음 쪽이 있을 수 있습니다. 건수가 아니라next_cursor로 끝을 판단하십시오.
용어 사전
페이소프트와 이 문서에서 쓰는 말입니다. 처음 보는 용어가 있으면 여기서 찾으십시오.
| 용어 | 뜻 | API 필드 |
|---|---|---|
| 이용기관 | 페이소프트에서 결제를 받는 단위(사업장)입니다. 한 가맹점에 여럿 연결될 수 있습니다. | account |
| 고객코드 | 페이소프트에 고객을 등록할 때 페이소프트가 자동으로 매기는 번호입니다. 가맹점이 정할 수 없습니다. | customer_code |
| 고객키 | 우리 회사가 이미 쓰는 회원번호입니다. 페이소프트 고객의 추가 항목에 넣어 두면 이 값으로 찾을 수 있습니다. | customer_key |
| 추가 항목 | 페이소프트 고객 정보에 가맹점이 자유롭게 쓰는 칸 15개입니다. | extra_fields |
| 구독 계약 | "매달 10일 2만 원"처럼 정기적으로 청구하는 약정 하나입니다. 한 고객이 여러 계약을 가질 수 있습니다. | contract_id |
| 청구월 | 어느 달 몫의 청구인지입니다. 실제 출금일과 다를 수 있습니다. | month, payment_month |
| 출금일 | 실제로 계좌·카드에서 돈을 빼려고 시도한 날입니다. | payment_date |
| 재출금 | 출금이 실패한 뒤 같은 달 안에 다시 시도하는 것입니다. 한 달에 출금 시도가 여러 번일 수 있습니다. | attempts |
| 자동 재결제 | 이용기관 설정에 따라 페이소프트가 실패 건을 스스로 다시 청구하는 기능입니다. 켜져 있으면 가맹점이 따로 청구하면 안 됩니다. | failure.auto_retry |
| CMS | 은행 계좌 자동이체입니다. 결제 결과는 보통 출금일 다음 영업일에 확정됩니다. | payment_kind |
| 결제수단 등록 | 자동이체·정기 카드를 결제사(효성CMS)에 등록하는 일입니다. 등록이 끝나야 청구할 수 있습니다. | auto_debit |
| 확인 필요 | 결제사 결과와 페이소프트 기록이 어긋나 결과를 단정할 수 없는 상태입니다. 페이소프트가 확인합니다. 다시 청구하면 안 됩니다. | unknown, review_required |
연동 가이드
데이터 한눈에 보기
객체 다섯 개가 아래처럼 이어집니다. 위에서 아래로 갈수록 잘게 쪼개집니다.
customer_codecustomer_keycontract_idcontract_id + monthtransaction_idmember_id| 객체 | 한 줄의 뜻 | 구별하는 값 | 언제 쓰나 |
|---|---|---|---|
| Billing | 계약 하나의 한 달 청구. 재출금이 있어도 한 줄로 합친 최종 상태 | contract_id + month | "이번 달 냈나?" — 대부분 이것만 쓰면 됩니다 |
| Payment | 출금 시도 한 번 | transaction_id | 건별 기록·정산 대사 |
| Contract | 구독 계약 하나 | contract_id | 계약 상태·금액·결제일 |
| Customer | 고객 한 명 (계약·자동이체 상태 포함) | customer_code | 회원 화면, 자동이체 등록 확인 |
| Member | 결제수단 하나 | customer_code + member_id | 결제수단별 등록 상태가 따로 필요할 때 |
예를 들면 — 10월에 10일 출금이 잔액부족으로 실패하고 15일 재출금이 성공했다면,
Payment 는 2건(실패 1, 성공 1)이고 Billing 은 1줄(paid, attempts: 2)입니다. 회원에게 보여 줄 것은 Billing 입니다.
우리 고객과 짝짓기 (고객키)
우리 회사 프로그램의 회원과 페이소프트 고객을 짝지으려면 고객키 하나만 정하면 됩니다. 고객키는 우리 회사가 회원마다 이미 쓰는 번호(회원번호·고객 ID 등)입니다.
- 페이소프트에 고객키 넣기 — 페이소프트 고객 정보의 추가 항목(1~15번) 중 하나에 회원번호를 넣습니다. 고객 등록·엑셀 일괄 등록 때 함께 넣거나, 이미 등록된 고객은 수정해서 넣습니다.
- 콘솔에서 항목 지정 — 콘솔 [고객키 설정]에서 회원번호를 넣은 항목을 고르고 저장합니다. 항목마다 값이 들어 있는 고객 수와 중복 수를 보여 주니, 추천 표시된 항목을 고르면 됩니다.
- API로 받기 — 이제 모든 응답에
customer_key가 붙고, 요청에customer_key=회원번호를 넣어 찾을 수 있습니다.
| 무엇을 맞추나 | 이 값으로 맞춥니다 | 우리 DB 에 저장할 것 |
|---|---|---|
| 고객 | customer_key (우리 회원번호) | 이미 가지고 있음. 처음 한 번 customer_code 도 함께 저장해 두면 좋습니다 |
| 구독 계약 | contract_id | 계약을 처음 받을 때 저장. 한 고객에 계약이 여러 개일 수 있습니다 |
| 월 결제 상태 | contract_id + month | 이 두 값을 키로 상태를 덮어씁니다(같은 달을 다시 받아도 한 줄) |
| 결제 한 건 | transaction_id | 건별 기록이 필요할 때만 |
고객키는 고객마다 하나뿐인 값이어야 합니다. 같은 값이 여러 고객에 들어 있으면 찾을 때 여러 명이 나옵니다.
고객키를 지정하지 않은 상태에서 customer_key 로 찾으면 422 CUSTOMER_KEY_NOT_SET 입니다.
고객키가 겹칠 때
페이소프트 추가 항목은 같은 값을 여러 고객에 넣을 수 있습니다(유일성 검사가 없음). 그래서 고객키가 겹칠 수 있고, 그때 API 는 이렇게 동작합니다.
| 상황 | API 동작 | 가맹점이 할 일 |
|---|---|---|
customer_key=값 으로 찾음 | 그 값을 가진 고객이 모두 나옵니다. /billings 는 그 고객들의 계약을 모두 돌려줍니다 | 한 회원의 것이라고 가정하지 마십시오 |
| 응답의 각 객체 | 같은 고객키를 다른 고객도 쓰면 customer_key_shared: true | true 인 줄은 자동 안내·자동 처리에서 빼고 사람이 확인 |
| 앞뒤 공백만 다른 값 | MEM-1 과 MEM-1␠ 은 같은 고객키로 봅니다. 응답의 customer_key 는 공백을 뗀 값입니다 | 저장할 때도 공백을 뗀 값으로 |
| 미사용 고객 | 사용하지 않는 고객도 검색·겹침 판정에 포함됩니다(in_use 로 구분) | 옛 고객에 남은 값도 정리 |
어디가 겹치는지 보기 — 콘솔 [고객키 설정]에서 항목마다 "겹치는 값 N개 · 고객 M명"을 보여 주고, 겹치는 값 보기에서 값별 고객코드를 확인할 수 있습니다(소유자·개발자). 페이소프트 [고객] 화면에서 그 고객의 추가 항목을 고객마다 다른 값으로 고치면 됩니다.
상황별 호출 고르기
| 하고 싶은 일 | 호출 | 예제 |
|---|---|---|
| 이번 달 모든 구독의 결제 상태를 우리 DB 에 반영 (하루 1~2번) | GET /v1/billings?month=202610 → next_cursor 가 없을 때까지 | 보기 |
| 미납 고객만 뽑아 안내 문자 보내기 | GET /v1/billings?month=202610&status=unpaid | 보기 |
| 회원 한 명 화면에 계약별 결제 상태 보여 주기 | GET /v1/billings?month=202610&customer_key=회원번호 | 보기 |
| 회원의 구독 계약과 자동이체 등록 상태 확인 | GET /v1/customers?customer_key=회원번호 | 보기 |
| 이번 달 청구·결제·미납 금액 한눈에 | GET /v1/billings/summary?month=202610 | |
| 날짜별 결제 건수·금액 (정산 대사) | GET /v1/payments/summary?from=2026-10-01&to=2026-10-31 | |
| 결제 한 건씩 자세히 | GET /v1/payments?month=202610&customer_key=회원번호 | |
| 실패한 결제를 다시 청구해도 되는지 판단 | Billing·Payment 의 status · review_required · failure | 보기 |
정기 동기화 설계
이 API 는 결제 결과를 알려 주는 알림(웹훅)을 보내지 않습니다. 우리 서버가 정해진 시각에 조회해 저장하는 방식으로 연동합니다.
| 무엇을 | 언제 | 이유 |
|---|---|---|
이번 달 /billings 전체 | 하루 1~2번 (예: 오전 10시·오후 3시) | 은행 자동이체 결과는 출금일 다음 영업일에 확정되는 경우가 많습니다 |
지난달 /billings 전체 | 월초 며칠 동안 하루 1번 | 월말 출금분 결과가 다음 달 초에 확정될 수 있습니다 |
회원 한 명 /billings?customer_key= | 회원이 화면을 열 때 (선택) | 최신 상태가 꼭 필요할 때만. 대개는 저장한 값을 보여 주면 됩니다 |
저장 테이블은 (contract_id, month) 를 기본키로 두고 받을 때마다 덮어쓰면 됩니다. 아래는 예시입니다(우리 회사 DB 에 맞게 바꾸십시오).
CREATE TABLE paysoft_billing ( contract_id BIGINT NOT NULL, -- Billing.contract_id month CHAR(6) NOT NULL, -- Billing.month (YYYYMM) member_no VARCHAR(200), -- Billing.customer_key (우리 회원번호) customer_code VARCHAR(50) NOT NULL, -- Billing.customer_code status VARCHAR(20) NOT NULL, -- paid · unpaid · in_progress · canceled · unknown status_name VARCHAR(50), -- 화면 표시용 한글 billed_amount INT NOT NULL, paid_amount INT NOT NULL, unpaid_amount INT NOT NULL, review_required BOOLEAN NOT NULL, -- true 면 재청구 금지 key_shared BOOLEAN NOT NULL, -- Billing.customer_key_shared (true 면 사람이 확인) failure_category VARCHAR(20), -- Billing.failure.category auto_retry BOOLEAN, -- Billing.failure.auto_retry as_of TIMESTAMP NOT NULL, -- 응답의 as_of PRIMARY KEY (contract_id, month) );
contract_id 가 null 인 줄(계약 없이 청구된 건)이 있으면 (customer_code, month) 로 따로 저장하십시오.
구독 계약으로만 청구한다면 이런 줄은 나오지 않습니다.
오류 처리와 재시도
오류가 나면 HTTP 상태 코드와 함께 아래 모양의 본문이 옵니다. code 로 분기하고, message 는 로그에 남기십시오.
{ "error": { "code": "RANGE_TOO_LONG", "message": "조회 기간은 최대 31일입니다.", "request_id": "3f2a9c1e-…" } }| 상태 | 다시 호출? | 어떻게 |
|---|---|---|
429 | 자동으로 | 응답 머리글 Retry-After(초)만큼 기다린 뒤 다시. 초당 한도는 1초, 하루 한도는 3600초가 옵니다 |
503 | 자동으로 | Retry-After(보통 30초) 뒤 다시. "결제 없음"으로 저장하지 마십시오 |
500 | 몇 번만 | 잠시 뒤 1~2번 다시. 계속되면 request_id 로 문의 |
401 · 403 | 하지 않음 | 키·IP·권한 설정 문제입니다. 반복 호출해도 같습니다. 담당자에게 알림을 보내십시오 |
404 · 422 | 하지 않음 | 요청 값을 고쳐야 합니다 |
| 연결 실패·시간 초과 | 자동으로 | 잠시 뒤 다시. 조회만 하므로 다시 호출해도 안전합니다 |
재시도를 넣은 공통 호출 함수는 예제에 있습니다.
결제 안전
재청구 전에 꼭 확인할 것
잘못 판단해 다시 청구하면 고객에게서 돈이 두 번 빠져나갑니다. 실패한 결제를 다시 청구하기 전에 위에서부터 차례로 확인하십시오. 하나라도 "하지 않음"이면 멈춥니다.
| 순서 | 확인할 값 | 이럴 때 | 다시 청구 |
|---|---|---|---|
| 1 | HTTP 상태 | 503 UPSTREAM_UNAVAILABLE — 원천 조회 실패 | 하지 않음 잠시 후 다시 조회 |
| 2 | HTTP 상태 | 404 NOT_FOUND — 조회 범위(정기결제)에서 찾지 못함 | 하지 않음 페이소프트에 확인 |
| 3 | review_required | true — 결제사 결과와 기록이 어긋났을 수 있어 페이소프트가 확인하는 중 | 하지 않음 |
| 4 | status | Payment: succeeded · pending · processing · canceled · unknownBilling: paid · in_progress · canceled · unknown | 하지 않음 |
| 5 | failure.auto_retry | true — 페이소프트 자동 재결제가 켜진 이용기관 | 하지 않음 자동 재결제를 기다림 |
| 6 | failure.retryable (Payment)failure.category (Billing) | true / 잔액부족·한도초과·일시장애 | 가능 고객 안내 후 |
| 7 | 그 밖의 실패 | 계좌·카드 상태, 본인확인, 카드사 확인 필요 등 | 고객 조치 후 다시 청구해도 같은 결과 |
503 을 '결제 없음'으로 해석하지 마십시오. 원천을 확인하지 못했다는 뜻입니다. 원천 조회에 실패했을 때 빈 목록이 오는 일은 없습니다.
이 순서를 그대로 옮긴 함수는 재청구 판단 함수 예제에 있습니다.
실패 분류표
failure.category 는 결제사 응답코드를 사람이 이해하기 쉬운 분류로 묶은 값입니다. 결제사 응답 원문은 개인정보가 섞일 수 있어 제공하지 않습니다.
| category | 뜻 | 다시 청구 | 고객 안내 예 |
|---|---|---|---|
잔액부족 | 계좌 잔액이 모자랐습니다 | 가능 | "잔액을 확인해 주세요" |
한도초과 | 카드·계좌 한도를 넘었습니다 | 가능 | "한도를 확인해 주세요" |
일시장애 | 은행·카드사 쪽 일시적인 장애 | 가능 | 안내 없이 나중에 다시 |
계좌상태 | 해지·거래정지 등 계좌를 쓸 수 없음 | 고객 조치 후 | "결제 계좌를 바꿔 주세요" |
카드상태 | 분실·정지·해지된 카드 | 고객 조치 후 | "결제 카드를 바꿔 주세요" |
카드정보오류 | 카드번호·유효기간 등이 맞지 않음 | 고객 조치 후 | "카드 정보를 다시 등록해 주세요" |
본인확인 | 예금주·본인 정보가 맞지 않음 | 고객 조치 후 | "결제수단 정보를 확인해 주세요" |
카드사확인 | 카드사가 거절. 고객이 카드사에 연락해야 풀림 | 고객 조치 후 | "카드사에 문의해 주세요" |
가맹점설정 · 요청오류 | 이용기관 설정이나 요청 자체의 문제 | 하지 않음 | 페이소프트에 문의 |
기타오류 | 실패는 확정됐지만 결제사가 사유를 밝히지 않음 | 확인 후 | 페이소프트에 문의 |
결과를 단정할 수 없는 응답(결과미확정·사전에 없는 코드)은 실패로 보내지 않고 status: unknown + review_required: true 로 보냅니다.
분류는 앞으로 늘어날 수 있으니 모르는 값은 "고객 조치 후"처럼 보수적으로 다루십시오.
보안 체크리스트
- API 키는 서버 환경변수나 비밀 저장소에만 둡니다. 소스코드·저장소·클라이언트(브라우저·앱)에 넣지 않습니다.
- 허용 IP 는 실제로 호출하는 서버 IP 만 등록합니다. 넓은 대역을 열지 않습니다.
- 환경마다 사용 중인 키를 2개까지 둘 수 있습니다. 새 키를 발급해 바꾼 뒤 옛 키를 폐기하면 중단 없이 교체됩니다.
- 키가 새었다고 의심되면 콘솔에서 즉시 폐기합니다. 폐기한 키로 들어온 요청은 곧바로 거부됩니다.
- 키의 유효 기간은 발급할 때 6개월 · 1년 · 기간 없음 중에서 고릅니다. 기간이 있는 키는 만료 전에 새 키로 교체하십시오. 기간 없음으로 발급했더라도 담당자가 바뀌거나 서버를 옮기면 새 키로 바꾸고 옛 키를 폐기하는 것을 권합니다.
- AI 코딩 도구에 코드를 보여 줄 때도 실제 키 값은 붙여 넣지 않습니다. 코드에는
PAYSOFT_API_KEY환경변수 이름만 남깁니다. - 추가 항목 값은 가맹점이 넣은 그대로 돌려줍니다. 개인정보를 넣었다면 받은 쪽에서도 개인정보로 다룹니다.
API 레퍼런스 · 월별 결제 상태
Billing 객체 구독 계약별 월 결제 상태
계약 하나의 한 달 청구를 한 줄로 보여 줍니다. 그 달에 실패 후 재출금이 있었더라도 한 줄로 합쳐 최종 상태를 알려 줍니다. 구독 결제 상태를 관리할 때 가장 먼저 쓰는 객체입니다.
| 필드 | 타입 | 설명 | 예시 |
|---|---|---|---|
| month | string | 청구월 YYYYMM. | "202610" |
| contract_id | numbernull 가능 | 구독 계약번호. 계약 없이 청구된 건이면 null(이때는 고객 단위로 묶임). | 1005 |
| contract_no | stringnull 가능 | 페이소프트 화면에 보이는 계약(주문)번호. | "TEST-ORD-1005" |
| contract_status | stringnull 가능 | 계약 상태 active 정상 · paused 일시 중지 · terminated 해지. | "active" |
| contract_status_name | stringnull 가능 | 계약 상태 한글 이름. | "정상" |
| account | string | 이용기관 코드. | "TEST" |
| customer_code | string | 페이소프트 고객코드(페이소프트가 자동으로 매김). | "C0005" |
| customer_key | stringnull 가능 | 우리 회사 고객키(고객키 설정을 한 경우). | "MEM-5" |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true. 한 회원으로 합쳐 저장하거나 안내를 보내기 전에 사람이 확인하십시오. 고객키가 겹칠 때 참고. | false |
| status | stringenum | 이 달의 결제 상태. 값 목록 참고. | "unpaid" |
| status_name | string | 상태 한글 이름. 화면에 그대로 써도 됩니다. | "미납" |
| review_required | boolean | true 이면 다시 청구하지 마십시오(status 가 unknown). | false |
| billed_amount | number | 청구 금액(원). 청구 취소면 0. | 30000 |
| paid_amount | number | 결제된 금액(원). paid 가 아니면 0. | 0 |
| unpaid_amount | number | 미납 금액(원). unpaid 일 때만 청구 금액과 같고, 그 밖에는 0. | 30000 |
| attempts | number | 그 달 출금 시도 횟수(재출금 포함). | 2 |
| paid_date | stringnull 가능 | 결제된 날 YYYY-MM-DD. paid 일 때만 값이 있습니다. | null |
| last_attempt_date | stringnull 가능 | 마지막 출금 시도일. | "2026-10-15" |
| failure | objectnull 가능 | 마지막 실패 정보. 미납·확인 필요·진행 중이면서 실패한 시도가 있을 때만 있습니다. | |
| failure.code | stringnull 가능 | 결제사 응답코드. | "Q301" |
| failure.category | string | 실패 분류. 실패 분류표 참고. | "잔액부족" |
| failure.auto_retry | boolean | true 면 페이소프트 자동 재결제가 켜진 이용기관. 직접 재청구하지 마십시오. | false |
Billing 의 failure 에는 retryable 이 없습니다. 다시 청구할지는 category 로 판단하십시오(예제).
status 값
| status | status_name | 뜻 | 가맹점이 할 일 |
|---|---|---|---|
paid | 결제 완료 | 그 달 청구가 결제됐습니다(재출금으로 성공한 경우 포함). | 없음 |
unpaid | 미납 | 그 달 시도가 모두 실패했습니다. 실패 사유는 failure. | 사유에 맞춰 고객 안내 |
in_progress | 결제 진행 중 | 출금 대기 또는 결제사 처리 중입니다. | 결과를 기다림 |
canceled | 청구 취소 | 그 달 청구가 취소됐습니다. | 없음 |
unknown | 확인 필요 — 재청구하지 마십시오 | 결과를 확정할 수 없어 페이소프트가 확인합니다. | 다시 청구하지 말 것 |
계약별 월 결제 상태 조회
청구월 하나의 Billing 목록을 돌려줍니다. 고객키·고객코드·계약번호·상태로 좁힐 수 있습니다.
| 파라미터 | 타입 | 설명 |
|---|---|---|
| month필수 | string | 청구월 YYYYMM. 예: 202610 |
| status | string | paid · unpaid · in_progress · canceled · unknown 중 하나로 거릅니다. |
| customer_key | string | 우리 회사 고객키로 거릅니다(최대 200자). |
| customer_code | string | 페이소프트 고객코드로 거릅니다. |
| contract_id | number | 계약 하나만 봅니다. |
| size | number | 한 쪽 건수 1~100, 기본 100. |
| cursor | string | 앞 응답의 next_cursor. 첫 쪽은 비웁니다. |
curl "https://api.paysoft.co.kr/v1/billings?month=202610&status=unpaid" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
r = requests.get("https://api.paysoft.co.kr/v1/billings",
params={"month": "202610", "status": "unpaid"},
headers={"Authorization": f"Bearer {os.environ['PAYSOFT_API_KEY']}"}, timeout=30)
for b in r.json()["data"]:
print(b["customer_key"], b["contract_id"], b["status_name"], b["unpaid_amount"])
const url = new URL("https://api.paysoft.co.kr/v1/billings");
url.search = new URLSearchParams({ month: "202610", status: "unpaid" });
const r = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PAYSOFT_API_KEY}` } });
const { data } = await r.json();
data.forEach(b => console.log(b.customer_key, b.contract_id, b.status_name, b.unpaid_amount));
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.paysoft.co.kr/v1/billings?month=202610&status=unpaid"))
.header("Authorization", "Bearer " + System.getenv("PAYSOFT_API_KEY"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// JSON 해석은 Jackson·Gson 등 쓰시는 라이브러리로
$ch = curl_init("https://api.paysoft.co.kr/v1/billings?" . http_build_query(["month" => "202610", "status" => "unpaid"]));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYSOFT_API_KEY")],
CURLOPT_TIMEOUT => 30,
]);
$body = json_decode(curl_exec($ch), true);
foreach ($body["data"] as $b) {
echo $b["customer_key"], " ", $b["status_name"], " ", $b["unpaid_amount"], PHP_EOL;
}
{
"as_of": "2026-10-31T09:00:00+09:00",
"request_id": "3f2a9c1e-…",
"data": [
{
"month": "202610", "contract_id": 1002, "contract_no": "TEST-ORD-1002",
"contract_status": "active", "contract_status_name": "정상",
"account": "TEST", "customer_code": "C0002", "customer_key": "MEM-2",
"status": "paid", "status_name": "결제 완료", "review_required": false,
"billed_amount": 30000, "paid_amount": 30000, "unpaid_amount": 0,
"attempts": 2, "paid_date": "2026-10-15", "last_attempt_date": "2026-10-15",
"failure": null
},
{
"month": "202610", "contract_id": 1005, "contract_no": "TEST-ORD-1005",
"contract_status": "active", "contract_status_name": "정상",
"account": "TEST", "customer_code": "C0005", "customer_key": "MEM-5",
"status": "unpaid", "status_name": "미납", "review_required": false,
"billed_amount": 30000, "paid_amount": 0, "unpaid_amount": 30000,
"attempts": 2, "paid_date": null, "last_attempt_date": "2026-10-15",
"failure": { "code": "Q301", "category": "잔액부족", "auto_retry": false }
}
],
"next_cursor": null
}청구월 집계
한 달 청구 전체를 상태별 계약 수와 금액으로 돌려줍니다. 대시보드나 월말 점검에 씁니다.
| 파라미터 | 타입 | 설명 |
|---|---|---|
| month필수 | string | 청구월 YYYYMM. |
| customer_key · customer_code · contract_id | 좁혀서 집계할 때(선택). |
| data 필드 | 타입 | 설명 |
|---|---|---|
| month | string | 청구월. |
| total | object | 전체 count(계약 수) · billed_amount · paid_amount · unpaid_amount. |
| by_status | array | 상태별 status · status_name · count · 금액 3종. paid·unpaid·in_progress·canceled·unknown 순서. |
| review_required | number | 확인 필요 계약 수. |
{
"month": "202610",
"total": { "count": 6, "billed_amount": 140000, "paid_amount": 50000, "unpaid_amount": 30000 },
"by_status": [
{ "status": "paid", "status_name": "결제 완료", "count": 2, "billed_amount": 50000, "paid_amount": 50000, "unpaid_amount": 0 },
{ "status": "unpaid", "status_name": "미납", "count": 1, "billed_amount": 30000, "paid_amount": 0, "unpaid_amount": 30000 },
…
],
"review_required": 1
}API 레퍼런스 · 결제결과
Payment 객체 출금 시도 한 건
결제 한 건의 결과입니다. 정기결제 원장의 거래 한 건이 Payment 객체 하나입니다. 재출금이 있으면 같은 달·같은 계약에 Payment 가 여러 건입니다.
| 필드 | 타입 | 설명 | 예시 |
|---|---|---|---|
| transaction_id | string | 거래번호. 결제 한 건을 식별합니다. 같은 거래는 언제나 이 값 하나로만 나타납니다. | "TEST20261010005" |
| account | string | 이용기관 코드. 가맹점에 이용기관이 여럿 연결돼 있으면 어느 이용기관의 거래인지 알려 줍니다. | "TEST" |
| customer_code | string | 페이소프트 고객코드. 페이소프트가 자동으로 매기는 번호라 가맹점이 정할 수 없습니다. 우리 회원번호로 찾으려면 고객키를 쓰십시오. | "C0005" |
| customer_key | stringnull 가능 | 우리 회사 고객키(고객키 설정을 한 경우). | "MEM-5" |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true. 한 회원으로 합쳐 저장하거나 안내를 보내기 전에 사람이 확인하십시오. 고객키가 겹칠 때 참고. | false |
| member_id | stringnull 가능 | 결제사(효성CMS) 회원 ID. | "M0005" |
| payment_kind | stringnull 가능 | 결제수단. CMS(자동이체), CARD(카드) 등. | "CMS" |
| contract_id | numbernull 가능 | 이 결제가 속한 구독 계약번호. 계약 없이 청구된 건은 null. | 1005 |
| payment_month | stringnull 가능 | 청구월 YYYYMM. | "202610" |
| payment_count | numbernull 가능 | 계약의 몇 번째 청구(회차)인지. | 10 |
| payment_date | stringnull 가능 | 출금일(결제일) YYYY-MM-DD. | "2026-10-10" |
| amount | numbernull 가능 | 청구 금액(원). | 30000 |
| paid_amount | numbernull 가능 | 실제로 받은 금액(원). status 가 succeeded 일 때만 값이 있습니다. | null |
| status | stringenum | 결제 상태. 값 목록 참고. | "failed" |
| status_code | string | 페이소프트 내부 상태 코드. 문의할 때 참고용이며 분기에 쓰지 마십시오. | "500" |
| review_required | boolean | true 이면 결제사 결과와 기록이 어긋났을 수 있어 사람이 확인하는 건입니다. 다시 청구하지 마십시오. | false |
| failure | objectnull 가능 | 실패 정보. 실패한 건(또는 실패 코드가 있는 확인 필요 건)에만 있습니다. | |
| failure.code | stringnull 가능 | 결제사 응답코드. | "Q301" |
| failure.category | string | 실패 분류. 실패 분류표 참고. | "잔액부족" |
| failure.auto_retry | boolean | true 이면 이 이용기관은 페이소프트 자동 재결제가 켜져 있습니다. 따로 다시 청구하면 이중 출금이 될 수 있습니다. | false |
| failure.retryable | boolean | 다시 청구해서 성공할 수 있는 실패인지. review_required 나 auto_retry 가 true 이면 언제나 false. | true |
| cancel_kind | stringnull 가능 | 취소 종류. canceled 일 때만 값이 있습니다. 값 목록 참고. | null |
| updated_at | stringnull 가능 | 원장에서 마지막으로 바뀐 시각. | "2026-10-10T18:00:00" |
status · cancel_kind 값
| status | 뜻 | 가맹점이 할 일 |
|---|---|---|
succeeded | 출금·승인이 성공했습니다. | 없음 |
failed | 출금·승인이 실패로 확정됐습니다. 사유는 failure. | 재청구 판단 |
pending | 출금일을 기다리고 있습니다. | 기다림 |
processing | 결제사가 처리 중입니다. 자동이체는 보통 출금일 다음 영업일에 결과가 확정됩니다. | 기다림 |
canceled | 취소됐습니다. 종류는 cancel_kind. | 없음 |
unknown | 결과를 확정할 수 없습니다. review_required 가 true. | 다시 청구하지 말 것 |
| cancel_kind | 뜻 |
|---|---|
before_withdrawal | 출금 전 취소 — 돈이 빠져나가지 않았습니다. |
approval_canceled | 카드 승인 취소. |
purchase_canceled | 카드 매입 취소. |
결제결과 목록
결제 시도 한 건씩 돌려줍니다. 출금일 기간(최대 31일) 또는 청구월 중 하나로 조회합니다. 한 달 계약별 최종 상태만 필요하면 /v1/billings 가 더 간단합니다.
| 파라미터 | 타입 | 설명 |
|---|---|---|
| from조건부 | string | 조회 시작 출금일 YYYY-MM-DD. |
| to조건부 | string | 조회 끝 출금일. from 부터 최대 31일. |
| month조건부 | string | 청구월 YYYYMM. from·to 대신 씁니다(함께 쓰면 오류). 둘 중 하나는 필수입니다. |
| status | string | succeeded · failed · pending · processing · canceled 중 하나로 거릅니다. |
| customer_key | string | 우리 회사 고객키로 거릅니다. |
| customer_code | string | 고객코드로 거릅니다. |
| contract_id | number | 계약 하나의 결제만 봅니다. |
| size | number | 한 쪽 건수 1~100, 기본 100. |
| cursor | string | 앞 응답의 next_cursor. |
curl "https://api.paysoft.co.kr/v1/payments?from=2026-10-01&to=2026-10-31&status=failed" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
import os, requests
url = "https://api.paysoft.co.kr/v1/payments"
headers = {"Authorization": f"Bearer {os.environ['PAYSOFT_API_KEY']}"}
params = {"from": "2026-10-01", "to": "2026-10-31", "status": "failed"}
items = []
while True:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status() # 503 이면 '결제 없음'으로 보지 말고 다시 시도
body = r.json()
items += body["data"]
if not body["next_cursor"]:
break
params["cursor"] = body["next_cursor"]
const url = new URL("https://api.paysoft.co.kr/v1/payments");
url.search = new URLSearchParams({ from: "2026-10-01", to: "2026-10-31", status: "failed" });
const r = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PAYSOFT_API_KEY}` } });
const body = await r.json();
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.paysoft.co.kr/v1/payments?from=2026-10-01&to=2026-10-31&status=failed"))
.header("Authorization", "Bearer " + System.getenv("PAYSOFT_API_KEY"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
$q = http_build_query(["from" => "2026-10-01", "to" => "2026-10-31", "status" => "failed"]);
$ch = curl_init("https://api.paysoft.co.kr/v1/payments?" . $q);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYSOFT_API_KEY")]]);
$body = json_decode(curl_exec($ch), true);
{
"as_of": "2026-10-31T09:00:00+09:00",
"request_id": "3f2a9c1e-…",
"data": [
{
"transaction_id": "TEST20261010005",
"account": "TEST",
"customer_code": "C0005",
"customer_key": "MEM-5",
"member_id": "M0005",
"payment_kind": "CMS",
"contract_id": 1005,
"payment_month": "202610",
"payment_count": 10,
"payment_date": "2026-10-10",
"amount": 30000,
"paid_amount": null,
"status": "failed",
"status_code": "500",
"review_required": false,
"failure": { "code": "Q301", "category": "잔액부족", "auto_retry": false, "retryable": true },
"cancel_kind": null,
"updated_at": "2026-10-10T18:00:00"
}
],
"next_cursor": null
}결제일별 집계
출금일마다 건수·청구 금액·결제 금액을 상태별로 돌려줍니다(최대 31일). 정산 대사나 일별 그래프에 씁니다.
| 파라미터 | 타입 | 설명 |
|---|---|---|
| from필수 | string | 출금일 시작 YYYY-MM-DD. |
| to필수 | string | 출금일 끝 (최대 31일). |
| customer_key · customer_code · contract_id | 좁혀서 집계할 때(선택). |
{
"from": "2026-10-01", "to": "2026-10-31",
"total": { "count": 8, "amount": 190000, "paid_amount": 50000, "review_required": 1 },
"days": [
{ "date": "2026-10-10", "count": 5, "amount": 110000, "paid_amount": 20000, "review_required": 1,
"by_status": {
"succeeded": { "status_name": "결제 성공", "count": 1, "amount": 20000, "paid_amount": 20000 },
"failed": { "status_name": "결제 실패", "count": 2, "amount": 60000, "paid_amount": 0 },
…
} }
]
}amount 는 시도한 청구 금액의 합입니다. 재출금이 있으면 같은 청구가 여러 번 더해지므로, 월 미납 금액은 청구월 집계로 보십시오.
결제결과 한 건
거래번호로 결제결과 한 건을 돌려줍니다. 응답의 data 는 Payment 객체 하나입니다.
curl "https://api.paysoft.co.kr/v1/payments/TEST20261010005" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
r = requests.get("https://api.paysoft.co.kr/v1/payments/TEST20261010005", headers=headers, timeout=30)
if r.status_code == 404:
# '없는 거래'로 단정하지 말 것 — 조회 범위 밖일 수 있다. 재청구 전에 페이소프트에 확인
...
404 는 이 API 의 조회 범위(정기결제)에서 찾지 못했다는 뜻입니다. 거래가 없다고 단정해 다시 청구하지 마십시오.
API 레퍼런스 · 구독 계약
Contract 객체 구독 계약
구독 계약 하나입니다. 한 고객에 계약이 여러 개일 수 있습니다. 삭제된 계약은 나오지 않습니다.
| 필드 | 타입 | 설명 | 예시 |
|---|---|---|---|
| contract_id | number | 계약번호. 결제결과·월 결제 상태의 contract_id 와 같습니다. | 1001 |
| contract_no | stringnull 가능 | 페이소프트 화면의 계약(주문)번호. | "TEST-ORD-1001" |
| account | string | 이용기관 코드. | "TEST" |
| customer_code | string | 고객코드. | "C0001" |
| customer_key | stringnull 가능 | 우리 회사 고객키. | "MEM-1" |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true. 한 회원으로 합쳐 저장하거나 안내를 보내기 전에 사람이 확인하십시오. 고객키가 겹칠 때 참고. | false |
| status | stringenum | active 정상 · paused 일시 중지 · terminated 해지 · 알 수 없는 코드면 unknown. | "active" |
| status_name | string | 정상 · 일시 중지 · 중도 해지 · 잔액 완납 해지 · 만기 해지 · 계약 해지. 해지 종류는 여기서 구분합니다. | "정상" |
| status_code | string | 페이소프트 내부 상태 코드(문의용). | "01" |
| payment_type | stringnull 가능 | CMS 은행 자동이체 · CARD 카드 정기결제 등. | "CMS" |
| payment_type_name | stringnull 가능 | 결제 방식 한글 이름. | "은행 자동이체" |
| payment_day | numbernull 가능 | 매달 결제 약정일(1~31). | 10 |
| monthly_amount | numbernull 가능 | 계약 금액(원). | 20000 |
| contract_start | stringnull 가능 | 약정 시작일. | "2026-01-01" |
| contract_end | stringnull 가능 | 약정 종료일. | "2027-12-31" |
| contract_months | numbernull 가능 | 약정 기간(개월). | 24 |
| billing_start | stringnull 가능 | 청구 시작일. | "2026-01-10" |
| billing_end | stringnull 가능 | 청구 종료일. | "2027-12-10" |
| billing_months | numbernull 가능 | 청구 횟수. | 24 |
| updated_at | stringnull 가능 | 마지막으로 바뀐 시각. | "2026-10-01T09:00:00" |
계약 목록
| 파라미터 | 타입 | 설명 |
|---|---|---|
| status | string | active · paused · terminated 중 하나로 거릅니다. |
| customer_key | string | 우리 회사 고객키로 거릅니다. |
| customer_code | string | 고객코드로 거릅니다. |
| size · cursor | number · string | 목록 넘기기. |
curl "https://api.paysoft.co.kr/v1/contracts?customer_key=MEM-1" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
r = requests.get("https://api.paysoft.co.kr/v1/contracts", params={"customer_key": "MEM-1"}, headers=headers, timeout=30)
for c in r.json()["data"]:
print(c["contract_id"], c["status_name"], c["payment_type_name"], c["monthly_amount"])
계약 한 건
응답의 data 는 Contract 객체 하나입니다. 없으면 404 NOT_FOUND.
API 레퍼런스 · 고객
Customer 객체 고객
고객 한 명의 고객키, 추가 항목, 자동이체 등록 상태, 구독 계약 목록을 한 번에 돌려줍니다. 이름·연락처·주소·계좌·카드번호는 제공하지 않습니다.
| 필드 | 타입 | 설명 | 예시 |
|---|---|---|---|
| account | string | 이용기관 코드. | "TEST" |
| customer_code | string | 페이소프트 고객코드(자동 부여). | "C0001" |
| customer_key | stringnull 가능 | 우리 회사 고객키. | "MEM-1" |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true. 한 회원으로 합쳐 저장하거나 안내를 보내기 전에 사람이 확인하십시오. 고객키가 겹칠 때 참고. | false |
| in_use | boolean | 페이소프트에서 사용 중인 고객인지. | true |
| extra_fields | array | 추가 항목 1~15 중 이름이 있거나 값이 들어 있는 항목. | |
| extra_fields[].no | number | 추가 항목 번호 1~15. | 1 |
| extra_fields[].name | stringnull 가능 | 페이소프트에서 정한 항목 이름. | "회원번호" |
| extra_fields[].value | stringnull 가능 | 값. | "MEM-1" |
| auto_debit | object | 자동이체(결제수단) 등록 상태 요약. | |
| auto_debit.status | stringenum | 고객 단위 등록 상태. 결제수단이 여럿이면 하나라도 등록 완료면 registered. 값 목록 참고. | "registered" |
| auto_debit.status_name | string | 한글 이름. | "등록 완료" |
| auto_debit.methods | array | 결제수단별 payment_kind · member_id · status · status_name · status_code · recurring · updated_at. | |
| contracts | array | 이 고객의 구독 계약 요약. contract_id · contract_no · status · status_name · payment_type · monthly_amount · billing_start · billing_end. | |
| created_at | stringnull 가능 | 페이소프트에 등록된 시각. | |
| updated_at | stringnull 가능 | 마지막으로 바뀐 시각. |
auto_debit.status 값
| status | status_name | 뜻 |
|---|---|---|
registered | 등록 완료 | 청구할 수 있습니다. |
pending | 등록 진행 중 | 결제사에 등록을 신청했고 결과를 기다립니다. |
failed | 등록 실패 | 고객 정보 확인이 필요합니다. |
deleted | 해지 | 결제사에서 해지됐습니다. |
none | 등록된 결제수단 없음 | 결제수단을 아직 등록하지 않았습니다. |
unknown | 확인 필요 | 그 밖의 상태입니다. |
고객 찾기·목록
| 파라미터 | 타입 | 설명 |
|---|---|---|
| customer_key | string | 우리 회원번호로 찾습니다. 가장 많이 쓰는 방법입니다. |
| customer_code | string | 고객코드로 찾습니다. |
| size · cursor | number · string | 목록 넘기기. |
curl "https://api.paysoft.co.kr/v1/customers?customer_key=MEM-1" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
r = requests.get("https://api.paysoft.co.kr/v1/customers", params={"customer_key": "MEM-1"}, headers=headers, timeout=30)
c = r.json()["data"][0]
print(c["auto_debit"]["status_name"], [x["contract_id"] for x in c["contracts"]])
{
"account": "TEST", "customer_code": "C0001", "customer_key": "MEM-1", "in_use": true,
"extra_fields": [
{ "no": 1, "name": "회원번호", "value": "MEM-1" },
{ "no": 2, "name": "회원 등급", "value": "VIP" }
],
"auto_debit": {
"status": "registered", "status_name": "등록 완료",
"methods": [ { "payment_kind": "CMS", "member_id": "M0001", "status": "registered",
"status_name": "등록 완료", "status_code": "900", "recurring": true, "updated_at": "…" } ]
},
"contracts": [
{ "contract_id": 1001, "contract_no": "TEST-ORD-1001", "status": "active", "status_name": "정상",
"payment_type": "CMS", "monthly_amount": 20000, "billing_start": "2026-01-10", "billing_end": "2027-12-10" },
{ "contract_id": 1006, "contract_no": "TEST-ORD-1006", "status": "active", "status_name": "정상",
"payment_type": "CARD", "monthly_amount": 30000, "billing_start": "2026-01-10", "billing_end": "2027-12-10" }
],
"created_at": "…", "updated_at": "…"
}추가 항목 값은 가맹점이 직접 넣은 값이라 그대로 돌려줍니다. 개인정보를 넣었다면 우리 회사 서버에서도 개인정보로 다루십시오.
고객 한 명
고객코드로 한 명을 봅니다. data 는 Customer 객체 하나이고, 여러 이용기관에 같은 고객코드가 있으면 배열입니다. 우리 회원번호로 찾으려면 GET /v1/customers?customer_key= 를 쓰십시오.
추가 항목 이름표
이용기관별로 사용 중인 추가 항목의 번호·이름·필수 여부·선택값과, 지금 고객키로 지정된 항목 번호(customer_key_field)를 돌려줍니다. 고객키 설정이 제대로 됐는지 프로그램에서 확인할 때 씁니다.
| data[] 필드 | 타입 | 설명 |
|---|---|---|
| account | string | 이용기관 코드. |
| customer_key_field | numbernull 가능 | 고객키로 지정한 추가 항목 번호. 미지정이면 null. |
| fields | array | 항목별 no · name · required(필수 여부) · options(선택형 항목의 선택값, 없으면 null). |
[ { "account": "TEST", "customer_key_field": 1,
"fields": [ { "no": 1, "name": "회원번호", "required": true, "options": null },
{ "no": 2, "name": "회원 등급", "required": false, "options": ["일반", "VIP"] } ] } ]API 레퍼런스 · 결제수단
Member 객체 결제수단
회원의 결제수단 하나와 그 등록 상태입니다. 한 회원이 결제수단을 여럿 가질 수 있어, 목록은 결제수단 단위로 나옵니다.
고객 단위로 한 번에 보려면 Customer 객체의 auto_debit 이 더 간단합니다.
| 필드 | 타입 | 설명 | 예시 |
|---|---|---|---|
| account | string | 이용기관 코드. | "TEST" |
| customer_code | string | 고객코드. | "C0001" |
| member_id | stringnull 가능 | 결제사(효성CMS) 회원 ID. | "M0001" |
| payment_kind | stringnull 가능 | 결제수단. | "CMS" |
| recurring | boolean | 정기결제가 켜져 있는지. | true |
| registration_status | stringenum | 결제사 등록 상태. 아래 값 목록 참고. | "registered" |
| registration_status_code | string | 페이소프트 내부 상태 코드(문의용). | "900" |
| updated_at | stringnull 가능 | 마지막으로 바뀐 시각. | "2026-10-01T09:00:00" |
| registration_status | 뜻 |
|---|---|
registered | 등록이 끝나 청구할 수 있습니다. |
pending | 등록 신청 중입니다. |
failed | 등록에 실패했습니다. 고객 정보 확인이 필요합니다. |
deleted | 결제사에서 삭제된 회원입니다. |
unknown | 그 밖의 상태입니다. |
결제수단 목록
| 파라미터 | 타입 | 설명 |
|---|---|---|
| customer_code | string | 고객코드로 거릅니다. |
| size | number | 1~100, 기본 100. |
| cursor | string | 앞 응답의 next_cursor. |
curl "https://api.paysoft.co.kr/v1/members?size=100" \ -H "Authorization: Bearer $PAYSOFT_API_KEY"
r = requests.get("https://api.paysoft.co.kr/v1/members", headers=headers, params={"size": 100}, timeout=30)
{
"as_of": "2026-10-31T09:00:00+09:00",
"request_id": "9b1c…",
"data": [
{
"account": "TEST", "customer_code": "C0001", "member_id": "M0001",
"payment_kind": "CMS", "recurring": true,
"registration_status": "registered", "registration_status_code": "900",
"updated_at": "2026-10-01T09:00:00"
}
],
"next_cursor": null
}고객 한 명의 결제수단
고객코드 하나의 결제수단 목록을 돌려줍니다. payment_methods 의 각 항목은 Member 와 같은 필드(customer_code 제외)입니다.
{
"customer_code": "C0001",
"accounts": ["TEST"],
"payment_methods": [
{ "account": "TEST", "member_id": "M0001", "payment_kind": "CMS", "recurring": true,
"registration_status": "registered", "registration_status_code": "900", "updated_at": "…" }
]
}예제 코드
공통 호출 함수 (재시도 포함)
모든 예제가 쓰는 기본 함수입니다. 키를 머리글에 넣고, 429·503 은 Retry-After 만큼 기다렸다 다시, 그 밖의 오류는 예외로 올립니다.
목록은 next_cursor 가 null 이 될 때까지 넘깁니다. 그대로 복사해 쓰셔도 됩니다.
# paysoft_client.py — pip install requests
import os
import time
import requests
BASE = "https://api.paysoft.co.kr/v1"
_session = requests.Session()
_session.headers["Authorization"] = f"Bearer {os.environ['PAYSOFT_API_KEY']}"
class PaysoftError(Exception):
def __init__(self, status, code, message, request_id):
super().__init__(f"{status} {code}: {message} (request_id={request_id})")
self.status, self.code, self.request_id = status, code, request_id
def call(path, params=None, max_tries=4):
"""GET 한 번. 429·503·연결 오류는 기다렸다 다시 시도한다."""
for attempt in range(1, max_tries + 1):
try:
r = _session.get(BASE + path, params=params, timeout=30)
except requests.RequestException:
if attempt == max_tries:
raise
time.sleep(2 ** attempt)
continue
if r.status_code == 200:
return r.json()
wait = int(r.headers.get("Retry-After", "0") or 0)
if r.status_code in (429, 503) and attempt < max_tries and wait <= 60:
time.sleep(wait or 5)
continue
err = r.json().get("error", {}) if "json" in r.headers.get("Content-Type", "") else {}
raise PaysoftError(r.status_code, err.get("code"), err.get("message"), err.get("request_id"))
def iter_all(path, params):
"""목록 API 의 모든 쪽을 차례로 돌려준다."""
params = dict(params)
while True:
body = call(path, params)
yield from body["data"]
if not body["next_cursor"]:
return
params["cursor"] = body["next_cursor"]
// paysoftClient.mjs — Node.js 18 이상
const BASE = "https://api.paysoft.co.kr/v1";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export class PaysoftError extends Error {
constructor(status, code, message, requestId) {
super(`${status} ${code}: ${message} (request_id=${requestId})`);
Object.assign(this, { status, code, requestId });
}
}
export async function call(path, params = {}, maxTries = 4) {
const url = new URL(BASE + path);
Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
for (let attempt = 1; attempt <= maxTries; attempt++) {
let r;
try {
r = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PAYSOFT_API_KEY}` },
signal: AbortSignal.timeout(30_000),
});
} catch (e) {
if (attempt === maxTries) throw e;
await sleep(2 ** attempt * 1000);
continue;
}
if (r.ok) return r.json();
const wait = Number(r.headers.get("Retry-After") || 0);
if ([429, 503].includes(r.status) && attempt < maxTries && wait <= 60) {
await sleep((wait || 5) * 1000);
continue;
}
const { error = {} } = await r.json().catch(() => ({}));
throw new PaysoftError(r.status, error.code, error.message, error.request_id);
}
}
export async function* iterAll(path, params) {
let cursor;
do {
const body = await call(path, { ...params, cursor });
yield* body.data;
cursor = body.next_cursor;
} while (cursor);
}
// PaysoftClient.java — Java 11 이상. JSON 해석은 Jackson(com.fasterxml.jackson.databind) 사용
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
public class PaysoftClient {
private static final String BASE = "https://api.paysoft.co.kr/v1";
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
private final ObjectMapper json = new ObjectMapper();
private final String key = System.getenv("PAYSOFT_API_KEY");
public JsonNode call(String path, Map<String, String> params) throws Exception {
String q = params.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path + (q.isEmpty() ? "" : "?" + q)))
.header("Authorization", "Bearer " + key).timeout(Duration.ofSeconds(30)).GET().build();
for (int attempt = 1; ; attempt++) {
HttpResponse<String> r = http.send(req, HttpResponse.BodyHandlers.ofString());
if (r.statusCode() == 200) return json.readTree(r.body());
long wait = r.headers().firstValueAsLong("Retry-After").orElse(0);
if ((r.statusCode() == 429 || r.statusCode() == 503) && attempt < 4 && wait <= 60) {
Thread.sleep((wait == 0 ? 5 : wait) * 1000);
continue;
}
throw new IllegalStateException(r.statusCode() + " " + r.body()); // error.code·request_id 를 로그에
}
}
/** 목록 API 의 모든 쪽을 차례로 넘긴다. */
public void forEach(String path, Map<String, String> params, Consumer<JsonNode> each) throws Exception {
Map<String, String> p = new LinkedHashMap<>(params);
while (true) {
JsonNode body = call(path, p);
body.get("data").forEach(each);
JsonNode next = body.get("next_cursor");
if (next == null || next.isNull()) return;
p.put("cursor", next.asText());
}
}
}
<?php
// paysoft_client.php — PHP 7.4 이상, curl 확장
const PAYSOFT_BASE = "https://api.paysoft.co.kr/v1";
function paysoft_call(string $path, array $params = [], int $maxTries = 4): array {
$url = PAYSOFT_BASE . $path . ($params ? "?" . http_build_query($params) : "");
for ($attempt = 1; ; $attempt++) {
$ch = curl_init($url);
$headers = [];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("PAYSOFT_API_KEY")],
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$p = explode(":", $line, 2);
if (count($p) === 2) $headers[strtolower(trim($p[0]))] = trim($p[1]);
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) return json_decode($body, true);
$wait = (int)($headers["retry-after"] ?? 0);
if (in_array($status, [429, 503, 0], true) && $attempt < $maxTries && $wait <= 60) {
sleep($wait ?: 5);
continue;
}
$err = json_decode((string)$body, true)["error"] ?? [];
throw new RuntimeException("$status " . ($err["code"] ?? "") . ": " . ($err["message"] ?? "")
. " (request_id=" . ($err["request_id"] ?? "") . ")");
}
}
function paysoft_iter_all(string $path, array $params): Generator {
while (true) {
$body = paysoft_call($path, $params);
yield from $body["data"];
if (empty($body["next_cursor"])) return;
$params["cursor"] = $body["next_cursor"];
}
}
월 결제 상태 동기화
이번 달 모든 구독의 결제 상태를 받아 우리 DB 에 저장합니다. 하루 1~2번 예약 작업(cron 등)으로 돌리면 됩니다. 저장 테이블은 정기 동기화 설계의 예시를 따릅니다.
from datetime import date
from paysoft_client import PaysoftError, call, iter_all
def sync_month(month: str, db) -> int:
"""month 예: "202610". 받은 줄 수를 돌려준다."""
as_of = None
n = 0
for b in iter_all("/billings", {"month": month}):
f = b["failure"] or {}
db.execute(
"""INSERT INTO paysoft_billing
(contract_id, month, member_no, customer_code, status, status_name,
billed_amount, paid_amount, unpaid_amount, review_required, key_shared,
failure_category, auto_retry, as_of)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE -- MySQL 기준. PostgreSQL 은 ON CONFLICT
status=VALUES(status), status_name=VALUES(status_name),
paid_amount=VALUES(paid_amount), unpaid_amount=VALUES(unpaid_amount),
review_required=VALUES(review_required), key_shared=VALUES(key_shared),
failure_category=VALUES(failure_category), auto_retry=VALUES(auto_retry), as_of=NOW()""",
(b["contract_id"], b["month"], b["customer_key"], b["customer_code"], b["status"],
b["status_name"], b["billed_amount"], b["paid_amount"], b["unpaid_amount"],
b["review_required"], b["customer_key_shared"], f.get("category"), f.get("auto_retry")))
n += 1
db.commit()
return n
if __name__ == "__main__":
try:
print(sync_month(date.today().strftime("%Y%m"), db=...)) # 우리 회사 DB 연결
except PaysoftError as e:
# 503 이 4번 이어져도 여기로 온다 — 저장된 상태를 지우지 말고 다음 실행을 기다린다
print("동기화 실패:", e)
import { iterAll, PaysoftError } from "./paysoftClient.mjs";
export async function syncMonth(month, db) { // month 예: "202610"
let n = 0;
for await (const b of iterAll("/billings", { month })) {
const f = b.failure ?? {};
await db.query(
`INSERT INTO paysoft_billing
(contract_id, month, member_no, customer_code, status, status_name,
billed_amount, paid_amount, unpaid_amount, review_required, failure_category, auto_retry, as_of)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now())
ON CONFLICT (contract_id, month) DO UPDATE SET -- PostgreSQL 기준
status = EXCLUDED.status, status_name = EXCLUDED.status_name,
paid_amount = EXCLUDED.paid_amount, unpaid_amount = EXCLUDED.unpaid_amount,
review_required = EXCLUDED.review_required, failure_category = EXCLUDED.failure_category,
auto_retry = EXCLUDED.auto_retry, as_of = now()`,
[b.contract_id, b.month, b.customer_key, b.customer_code, b.status, b.status_name,
b.billed_amount, b.paid_amount, b.unpaid_amount, b.review_required, f.category ?? null, f.auto_retry ?? null]);
n++;
}
return n;
}
PaysoftClient client = new PaysoftClient();
String month = java.time.YearMonth.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMM"));
client.forEach("/billings", Map.of("month", month), b -> {
JsonNode f = b.get("failure");
billingRepository.upsert( // (contract_id, month) 기준으로 덮어쓰기
b.get("contract_id").asLong(), b.get("month").asText(),
b.get("customer_key").asText(null), b.get("customer_code").asText(),
b.get("status").asText(), b.get("status_name").asText(),
b.get("billed_amount").asInt(), b.get("paid_amount").asInt(), b.get("unpaid_amount").asInt(),
b.get("review_required").asBoolean(),
f.isNull() ? null : f.get("category").asText(),
f.isNull() ? null : f.get("auto_retry").asBoolean());
});
<?php
require "paysoft_client.php";
$pdo = new PDO(/* 우리 회사 DB */);
$stmt = $pdo->prepare(
"INSERT INTO paysoft_billing (contract_id, month, member_no, customer_code, status, status_name,
billed_amount, paid_amount, unpaid_amount, review_required, failure_category, auto_retry, as_of)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?, NOW())
ON DUPLICATE KEY UPDATE status=VALUES(status), status_name=VALUES(status_name),
paid_amount=VALUES(paid_amount), unpaid_amount=VALUES(unpaid_amount),
review_required=VALUES(review_required), failure_category=VALUES(failure_category),
auto_retry=VALUES(auto_retry), as_of=NOW()");
foreach (paysoft_iter_all("/billings", ["month" => date("Ym")]) as $b) {
$f = $b["failure"] ?? [];
$stmt->execute([$b["contract_id"], $b["month"], $b["customer_key"], $b["customer_code"],
$b["status"], $b["status_name"], $b["billed_amount"], $b["paid_amount"], $b["unpaid_amount"],
(int)$b["review_required"], $f["category"] ?? null, isset($f["auto_retry"]) ? (int)$f["auto_retry"] : null]);
}
호출이 실패하면 저장된 상태를 지우거나 "미납"으로 바꾸지 마십시오. 다음 실행에서 다시 받으면 됩니다.
미납 안내 대상 뽑기
이번 달 미납 계약을 뽑아 실패 사유에 맞는 안내 문구를 고릅니다. 확인 필요(review_required), 자동 재결제 대상, 고객키가 겹치는 줄(customer_key_shared)은 안내에서 뺍니다.
from paysoft_client import iter_all
GUIDE = {
"잔액부족": "결제 계좌의 잔액이 부족해 이번 달 이용료가 출금되지 않았습니다.",
"한도초과": "결제 한도를 넘어 이번 달 이용료가 결제되지 않았습니다.",
"계좌상태": "결제 계좌를 사용할 수 없습니다. 결제 계좌를 바꿔 주세요.",
"카드상태": "결제 카드를 사용할 수 없습니다. 결제 카드를 바꿔 주세요.",
"카드사확인": "카드사에서 결제를 거절했습니다. 카드사에 문의해 주세요.",
}
def unpaid_targets(month):
for b in iter_all("/billings", {"month": month, "status": "unpaid"}):
f = b["failure"] or {}
if b["review_required"] or f.get("auto_retry"):
continue # 페이소프트 확인·자동 재결제 대기 — 안내하지 않음
if b["customer_key_shared"]:
continue # 고객키가 다른 고객과 겹침 — 사람이 확인 후 안내
yield {
"member_no": b["customer_key"], # 우리 회원번호 → 우리 DB 에서 연락처를 찾는다
"contract_id": b["contract_id"],
"amount": b["unpaid_amount"],
"message": GUIDE.get(f.get("category"), "이번 달 이용료가 결제되지 않았습니다. 고객센터로 문의해 주세요."),
}
import { iterAll } from "./paysoftClient.mjs";
const GUIDE = {
잔액부족: "결제 계좌의 잔액이 부족해 이번 달 이용료가 출금되지 않았습니다.",
한도초과: "결제 한도를 넘어 이번 달 이용료가 결제되지 않았습니다.",
계좌상태: "결제 계좌를 사용할 수 없습니다. 결제 계좌를 바꿔 주세요.",
카드상태: "결제 카드를 사용할 수 없습니다. 결제 카드를 바꿔 주세요.",
카드사확인: "카드사에서 결제를 거절했습니다. 카드사에 문의해 주세요.",
};
export async function unpaidTargets(month) {
const out = [];
for await (const b of iterAll("/billings", { month, status: "unpaid" })) {
const f = b.failure ?? {};
if (b.review_required || f.auto_retry) continue; // 안내하지 않음
if (b.customer_key_shared) continue; // 고객키가 겹침 — 사람이 확인
out.push({ memberNo: b.customer_key, contractId: b.contract_id, amount: b.unpaid_amount,
message: GUIDE[f.category] ?? "이번 달 이용료가 결제되지 않았습니다. 고객센터로 문의해 주세요." });
}
return out;
}
재청구 판단 함수
재청구 전에 꼭 확인할 것의 순서를 그대로 옮겼습니다. Billing 과 Payment 를 모두 받습니다. 결과가 False 이면 절대 다시 청구하지 마십시오.
RETRYABLE_CATEGORIES = {"잔액부족", "한도초과", "일시장애"}
FAILED = {"unpaid", "failed"} # Billing.status / Payment.status 의 실패 값
def can_recharge(item: dict) -> tuple[bool, str]:
"""item: Billing 또는 Payment 객체. (다시 청구해도 되는가, 이유)"""
if item.get("review_required"):
return False, "확인 필요 — 페이소프트 확인을 기다립니다"
if item.get("status") not in FAILED:
return False, f"실패 상태가 아님({item.get('status')})"
f = item.get("failure") or {}
if f.get("auto_retry"):
return False, "자동 재결제 대상 — 페이소프트가 다시 청구합니다"
if "retryable" in f: # Payment 에만 있음
ok = f["retryable"]
else: # Billing 은 분류로 판단
ok = f.get("category") in RETRYABLE_CATEGORIES
return (True, "고객 안내 후 다시 청구 가능") if ok else (False, f"고객 조치 필요({f.get('category')})")
const RETRYABLE_CATEGORIES = new Set(["잔액부족", "한도초과", "일시장애"]);
const FAILED = new Set(["unpaid", "failed"]);
/** item: Billing 또는 Payment. { ok, reason } */
export function canRecharge(item) {
if (item.review_required) return { ok: false, reason: "확인 필요 — 페이소프트 확인을 기다립니다" };
if (!FAILED.has(item.status)) return { ok: false, reason: `실패 상태가 아님(${item.status})` };
const f = item.failure ?? {};
if (f.auto_retry) return { ok: false, reason: "자동 재결제 대상" };
const ok = "retryable" in f ? f.retryable : RETRYABLE_CATEGORIES.has(f.category);
return ok ? { ok: true, reason: "고객 안내 후 다시 청구 가능" }
: { ok: false, reason: `고객 조치 필요(${f.category})` };
}
static final Set<String> RETRYABLE_CATEGORIES = Set.of("잔액부족", "한도초과", "일시장애");
static final Set<String> FAILED = Set.of("unpaid", "failed");
/** item: Billing 또는 Payment(JsonNode). true 일 때만 다시 청구한다. */
static boolean canRecharge(JsonNode item) {
if (item.path("review_required").asBoolean(false)) return false;
if (!FAILED.contains(item.path("status").asText())) return false;
JsonNode f = item.path("failure");
if (f.isMissingNode() || f.isNull()) return false;
if (f.path("auto_retry").asBoolean(false)) return false;
if (f.has("retryable")) return f.get("retryable").asBoolean(); // Payment
return RETRYABLE_CATEGORIES.contains(f.path("category").asText()); // Billing
}
회원 화면에 결제 상태 표시
회원 한 명의 계약과 이번 달 결제 상태를 함께 보여 줍니다. 보통은 동기화해 둔 우리 DB 값을 보여 주고, "새로 고침"을 눌렀을 때만 API 를 부르면 호출 한도를 아낄 수 있습니다.
from paysoft_client import call
def member_billing_view(member_no: str, month: str) -> dict:
customers = call("/customers", {"customer_key": member_no})["data"]
if not customers:
return {"registered": False}
c = customers[0]
billings = {b["contract_id"]: b for b in call("/billings", {"month": month, "customer_key": member_no})["data"]}
return {
"registered": True,
"auto_debit": c["auto_debit"]["status_name"], # 예: 등록 완료
"contracts": [
{
"contract_id": k["contract_id"],
"contract_status": k["status_name"], # 예: 정상
"monthly_amount": k["monthly_amount"],
"this_month": billings.get(k["contract_id"], {}).get("status_name", "청구 없음"),
}
for k in c["contracts"]
],
}
import { call } from "./paysoftClient.mjs";
export async function memberBillingView(memberNo, month) {
const { data: customers } = await call("/customers", { customer_key: memberNo });
if (!customers.length) return { registered: false };
const c = customers[0];
const { data: rows } = await call("/billings", { month, customer_key: memberNo });
const billings = new Map(rows.map((b) => [b.contract_id, b]));
return {
registered: true,
autoDebit: c.auto_debit.status_name,
contracts: c.contracts.map((k) => ({
contractId: k.contract_id, contractStatus: k.status_name, monthlyAmount: k.monthly_amount,
thisMonth: billings.get(k.contract_id)?.status_name ?? "청구 없음",
})),
};
}
AI 도구로 연동
AI 코딩 도구에 문서 주기
ChatGPT·Claude·Copilot·Cursor 같은 AI 코딩 도구에 이 문서를 주면 우리 회사 언어와 구조에 맞는 연동 코드를 빠르게 만들 수 있습니다. AI 가 읽기 쉽도록 같은 내용을 Markdown 한 파일로도 제공합니다.
콘솔에서 바로 만들기 — 가입한 가맹점은 콘솔 [연동 코드 만들기]에서 언어(Python·Java·PHP·Node.js)·DB·기능만 고르면 이 문서를 바탕으로 AI 가 연동 코드를 만들어 드립니다(하루 5회).
- 문서 주기 — 위 전체 복사를 눌러 AI 대화창에 붙여 넣거나, 주소(
https://console.paysoft.co.kr/portal/docs.md)를 읽을 수 있는 도구라면 주소를 알려 줍니다. - 요청하기 — 아래 요청문을 우리 회사 상황에 맞게 고쳐 붙여 넣습니다.
- 테스트 키로 확인 — 만들어진 코드를 테스트 키로 돌려 테스트 환경 표와 같은 결과가 나오는지 봅니다.
실제 API 키는 AI 대화창에 붙여 넣지 마십시오. 코드에는 PAYSOFT_API_KEY 환경변수 이름만 쓰게 하십시오.
AI 가 만든 코드는 반드시 사람이 검토하고, 특히 재청구 판단 부분은 이 문서의 규칙과 같은지 확인하십시오.
바로 쓰는 요청문
아래 "페이소프트 가맹점 조회 API" 문서를 읽고 연동 코드를 만들어 줘. [우리 환경] - 언어/프레임워크: (예: Python 3.12 + Django / Java 17 + Spring Boot / PHP 8 + Laravel / Node.js 20) - DB: (예: MySQL 8 / PostgreSQL 16 / Oracle) - 우리 회원번호 컬럼: (예: members.member_no) — 페이소프트 고객키(customer_key)와 같은 값 - 실행 방식: (예: 매일 10시·15시 cron / Spring @Scheduled) [만들 것] 1. 공통 호출 모듈: Authorization: Bearer 키(환경변수 PAYSOFT_API_KEY), 429·503 은 Retry-After 만큼 기다렸다 재시도, next_cursor 로 모든 쪽 넘기기 2. 이번 달 /v1/billings 를 받아 (contract_id, month) 기준으로 우리 DB 에 덮어쓰는 동기화 작업 3. 미납(status=unpaid) 회원 목록 조회 함수 4. 테스트 코드 (테스트 키의 가상 데이터 기준: 1001 paid, 1002 paid(재출금), 1003 unknown, 1004 canceled, 1005 unpaid, 1006 in_progress) [반드시 지킬 것] - 이 API 는 조회만 한다. 결제·취소 호출을 만들지 마. - status 가 unknown 이거나 review_required 가 true 면 재청구 대상에서 빼. - failure.auto_retry 가 true 면 재청구 대상에서 빼. - customer_key_shared 가 true 면 다른 고객과 고객키가 겹친 것이니 자동 안내·자동 처리에서 빼고 따로 표시해. - 503 을 '결제 없음'으로 저장하지 말고, 호출이 실패하면 기존 저장값을 지우지 마. - 문서에 없는 필드나 엔드포인트를 지어내지 마. 모르는 응답 필드는 무시해. - API 키를 코드에 쓰지 마. [문서] (여기에 /portal/docs.md 내용을 붙여 넣기)
참고
오류 코드
{ "error": { "code": "RANGE_TOO_LONG", "message": "조회 기간은 최대 31일입니다.", "request_id": "…" } }| HTTP | code | 뜻과 조치 | 다시 호출 |
|---|---|---|---|
| 401 | INVALID_KEY | 키가 없거나 틀렸거나 폐기·만료됐습니다. 콘솔에서 키 상태를 확인하십시오. | 아니오 |
| 403 | IP_NOT_ALLOWED | 키에 등록하지 않은 IP 에서 호출했습니다. | 아니오 |
| 403 | SCOPE_DENIED | 이 키로 쓸 수 없는 API 입니다(승인된 범위 밖). 권한 표 참고. | 아니오 |
| 403 | MERCHANT_INACTIVE | 이용이 정지됐거나 승인 전이거나, 연결된 이용기관이 없습니다. | 아니오 |
| 404 | NOT_FOUND | 조회 범위에서 찾지 못했습니다. 다른 가맹점의 데이터도 같은 응답입니다. | 아니오 |
| 422 | INVALID_PARAM | 요청 값 형식이 맞지 않습니다. message 에 문제 된 파라미터가 있습니다. | 아니오 |
| 422 | RANGE_TOO_LONG | 조회 기간이 31일을 넘었습니다. | 아니오 |
| 422 | CUSTOMER_KEY_NOT_SET | 고객키 항목을 지정하지 않고 customer_key 로 찾았습니다. 콘솔 [고객키 설정]에서 지정하십시오. | 아니오 |
| 422 | RESULT_TOO_LARGE | 한 번에 집계하기에 건수가 너무 많습니다. 페이소프트에 문의하십시오. | 아니오 |
| 429 | RATE_LIMITED | 호출 한도를 넘었습니다. Retry-After 초 뒤에 다시 호출하십시오. | 기다린 뒤 |
| 503 | UPSTREAM_UNAVAILABLE | 원천 조회에 실패했습니다. 빈 결과로 해석하지 말고 Retry-After 뒤 다시 조회하십시오. | 기다린 뒤 |
| 500 | INTERNAL | 처리 중 오류입니다. request_id 를 알려 주십시오. | 몇 번만 |
호출 한도
가맹점마다 초당·하루 호출 한도가 있습니다. 한도는 승인할 때 정하며 콘솔 첫 화면에서 확인할 수 있습니다(기본 초당 2회, 하루 10,000회). 초당 한도는 가맹점 단위로 세므로 키를 여러 개 써도 늘어나지 않습니다.
| 넘었을 때 | 응답 | Retry-After |
|---|---|---|
| 초당 한도 | 429 RATE_LIMITED "초당 호출 한도를 넘었습니다." | 1초 |
| 하루 한도 | 429 RATE_LIMITED "하루 호출 한도를 넘었습니다." | 3600초 |
한도를 아끼는 법 — 회원마다 따로 부르지 말고 /billings?month= 로 한 달 전체를 받아(100건씩) 저장하십시오.
회원 1만 명이면 하루 2번 동기화에 약 200번이면 충분합니다.
자주 묻는 질문
결제 요청이나 취소도 이 API 로 할 수 있나요?
아니요. 이 API 는 조회만 제공합니다. 결제·취소·환불·회원 등록은 페이소프트 관리자 화면에서 처리합니다.
결제가 끝나면 알림(웹훅)을 받을 수 있나요?
지금은 제공하지 않습니다. 하루 1~2번 /billings 를 조회해 저장하는 방식을 권합니다(정기 동기화 설계).
브라우저(자바스크립트)나 모바일 앱에서 바로 호출해도 되나요?
안 됩니다. 키가 노출되고, 허용 IP 에서만 호출할 수 있어 동작하지도 않습니다. 우리 회사 서버에서 호출하고 화면에는 서버가 저장한 값을 보여 주십시오.
고객코드(customer_code)를 우리 회원번호로 정할 수 있나요?
고객코드는 페이소프트가 자동으로 매깁니다. 대신 추가 항목에 회원번호를 넣고 고객키로 지정하면 우리 회원번호로 찾을 수 있습니다.
같은 회원번호가 여러 고객에 들어가 있으면 어떻게 되나요?
페이소프트 추가 항목은 같은 값을 막지 않습니다. 그 값으로 찾으면 고객이 모두 나오고 응답의 customer_key_shared 가 true 입니다.
콘솔 [고객키 설정] → 겹치는 값 보기에서 고객코드를 확인해 페이소프트에서 고쳐 주십시오(자세히).
Billing 과 Payment 중 무엇을 써야 하나요?
"이번 달 냈나?"는 Billing 입니다. 재출금까지 합친 최종 상태 한 줄이라 판단이 쉽습니다. 출금 시도마다 기록이 필요하거나 날짜별 정산 대사를 할 때만 Payment 를 쓰십시오.
status 가 unknown 인 건은 어떻게 하나요?
결제사 결과와 기록이 어긋나 결과를 확정하지 못한 건입니다. 페이소프트가 확인하니 다시 청구하지 말고 기다리십시오. 오래 남아 있으면 request_id 와 함께 문의하십시오.
테스트 키로 개발한 코드를 운영에서 그대로 써도 되나요?
네. 주소와 응답 형식이 같으니 환경변수의 키만 psk_live_ 로 바꾸면 됩니다. 운영 데이터에서 customer_key 를 쓰려면 고객키 설정을 먼저 하십시오.
응답에 모르는 필드가 생겼어요.
필드가 늘어나는 변경은 버전을 바꾸지 않고 반영합니다. 모르는 필드는 무시하도록 만드십시오. 바뀐 내용은 변경 이력에 적습니다.
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-10-12 | customer_key_shared 추가(Billing·Payment·Contract·Customer) — 같은 고객키를 다른 고객도 쓰면 true.
고객키 검색·응답은 앞뒤 공백을 무시합니다 |
| 2026-10-12 | API 주소 확정 — https://api.paysoft.co.kr/v1 (API 전용 도메인). 콘솔 도메인의 /partner/ 경로는 쓰지 않습니다 |
| 2026-10-12 | 문서 개편(API 변경 없음) — 객체·파라미터 표, 상태값 표, 실패 분류표, 용어 사전, 언어별 예제(Python·Node.js·Java·PHP), 문서 검색, AI 도구용 Markdown판(/portal/docs.md) 추가 |
| 2026-10-09 | 구독 계약(/contracts), 계약별 월 결제 상태(/billings, /billings/summary), 결제일별 집계(/payments/summary),
고객(/customers — 추가 항목 15개·자동이체 등록 상태), 추가 항목 이름표(/extra-fields) 추가.
고객키(customer_key) 매칭, 결제결과에 contract_id · payment_count · month 조회 추가.
테스트 환경 가상 데이터를 계약별 월 시나리오로 변경 |
| 2026-10-09 | v1 공개 — 결제결과·회원 조회, 테스트 환경. failure.auto_retry 추가 |
응답에 필드가 새로 늘어나는 변경은 버전을 바꾸지 않고 반영합니다. 모르는 필드는 무시하도록 연동해 주십시오.