# 페이소프트 가맹점 조회 API — 연동 문서 (AI·도구용 Markdown판)

> 사람이 읽는 문서: https://console.paysoft.co.kr/portal/docs — 이 파일과 내용이 같습니다.
> 이 문서는 AI 코딩 도구가 연동 코드를 만들 때 쓰도록 한 파일로 정리한 것입니다. **여기 없는 엔드포인트·필드·파라미터를 지어내지 마십시오.**

## 0. AI 가 반드시 지킬 규칙

1. 이 API 는 **조회(GET)만** 제공합니다. 결제 요청·취소·환불·회원 등록/변경 API 는 없습니다. 만들지 마십시오.
2. API 키는 코드에 쓰지 말고 환경변수 `PAYSOFT_API_KEY` 에서 읽습니다. 브라우저·모바일 앱에서 호출하지 않습니다(허용 IP 서버에서만 동작).
3. `status` 가 `unknown` 이거나 `review_required` 가 `true` 이면 **재청구 금지**.
4. `failure.auto_retry` 가 `true` 이면 **재청구 금지**(페이소프트가 자동 재결제).
5. HTTP `503 UPSTREAM_UNAVAILABLE` 은 "결제 없음"이 아닙니다. 빈 결과로 저장하지 말고, 기존 저장값을 지우지 말고, `Retry-After` 뒤 다시 조회합니다.
6. `404 NOT_FOUND` 는 "조회 범위(정기결제)에서 못 찾음"입니다. 거래가 없다고 단정해 재청구하지 않습니다.
7. 목록은 `next_cursor` 가 `null` 이 될 때까지 넘깁니다. 건수가 `size` 보다 적어도 `next_cursor` 가 있으면 계속합니다.
8. 응답의 모르는 필드는 무시합니다(필드는 예고 없이 늘어날 수 있음).
9. `status_code`·`registration_status_code` 는 문의용 내부 코드입니다. 분기에 쓰지 말고 `status` 를 씁니다.
10. `customer_key_shared` 가 `true` 이면 같은 고객키를 다른 고객도 씁니다. 한 회원으로 합치거나 자동 안내하지 말고 사람이 확인하도록 표시합니다.

## 1. 기본

- 기본 주소(BASE): `https://api.paysoft.co.kr/v1` — API 전용 도메인. 콘솔(console.paysoft.co.kr)과 다름. 테스트 키·운영 키 모두 같은 주소
- 인증: 모든 요청에 `Authorization: Bearer <API 키>`
  - `psk_test_…` 시험 키 — 가상 데이터만(아래 6장). `psk_live_…` 운영 키 — 실제 데이터.
- 검사 순서: 키(401 `INVALID_KEY`) → 허용 IP(403 `IP_NOT_ALLOWED`) → 가맹점 상태(403 `MERCHANT_INACTIVE`) → 권한(403 `SCOPE_DENIED`) → 호출 한도(429 `RATE_LIMITED`)
- 권한(scope)
  - `payments:read`: `/billings`, `/billings/summary`, `/payments`, `/payments/summary`, `/payments/{transaction_id}`
  - `members:read`: `/contracts`, `/contracts/{contract_id}`, `/customers`, `/customers/{customer_code}`, `/extra-fields`, `/members`, `/members/{customer_code}`
- 형식: UTF-8 JSON. 날짜 `YYYY-MM-DD`, 청구월 `YYYYMM`, 시각 ISO 8601. 금액은 원 단위 정수. 값이 없으면 필드를 빼지 않고 `null`.
- 모든 응답은 `Cache-Control: no-store`. 응답 머리글 `X-Request-Id` = 본문 `request_id`.
- 호출 한도: 가맹점 단위 초당·하루 한도(기본 초당 2회, 하루 10,000회). 초과 시 429, `Retry-After` 초당 1 / 하루 3600.

### 성공 응답 봉투

```json
{ "as_of": "2026-10-31T09:00:00+09:00", "request_id": "3f2a9c1e-…", "data": [ … ], "next_cursor": null }
```

| 필드 | 타입 | 설명 |
|---|---|---|
| as_of | string | 원천 조회 시각(KST). 상태와 함께 저장 |
| request_id | string | 요청 번호(문의용) |
| data | array \| object | 목록이면 배열, 한 건이면 객체 |
| next_cursor | string \| null | 목록 API 에만. 다음 요청의 `cursor` 에 그대로. `null` 이면 마지막 쪽 |

### 오류 응답

```json
{ "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 | 요청 값 형식 오류 | 아니오 |
| 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`(보통 30초) 뒤 |
| 500 | INTERNAL | 처리 중 오류 | 1~2번만 |

## 2. 데이터 관계

```
Customer(고객, customer_code / customer_key)
 ├─ 1:N Contract(구독 계약, contract_id)
 │        └─ 매달 1줄 Billing(계약×청구월, contract_id+month) ── 1:N Payment(출금 시도, transaction_id)
 └─ 1:N Member(결제수단, member_id)
```

- "이번 달 냈나?" → **Billing** (재출금까지 합친 최종 상태 한 줄). 대부분 이것만 쓰면 됨.
- 우리 DB 저장 키: Billing = `(contract_id, month)` 로 덮어쓰기. `contract_id` 가 null 이면 `(customer_code, month)`.
- 고객키가 겹칠 수 있음: 페이소프트 추가 항목에는 유일성 검사가 없다. `customer_key=값` 검색은 그 값을 가진 고객을 모두 돌려주고, 각 객체의 `customer_key_shared` 가 true. 앞뒤 공백은 무시(응답 customer_key 도 공백 제거), 미사용 고객도 포함(`in_use`로 구분). 가맹점은 콘솔 [고객키 설정] → 겹치는 값 보기에서 고객코드 확인.
- 고객키(`customer_key`): 가맹점 회원번호. 페이소프트 고객 추가 항목(1~15) 중 하나에 넣고 콘솔 [고객키 설정]에서 지정해야 쓸 수 있음. `customer_code` 는 페이소프트 자동 부여 번호.
- 웹훅 없음. 하루 1~2번 이번 달 `/billings` 전체를 조회해 저장(월초 며칠은 지난달도).

## 3. 객체

### Billing — 계약별 월 결제 상태

| 필드 | 타입 | 설명 |
|---|---|---|
| month | string | 청구월 YYYYMM |
| contract_id | number \| null | 구독 계약번호. 계약 없이 청구된 건은 null |
| contract_no | string \| null | 화면의 계약(주문)번호 |
| contract_status | string \| null | active · paused · terminated |
| contract_status_name | string \| null | 계약 상태 한글 |
| account | string | 이용기관 코드 |
| customer_code | string | 페이소프트 고객코드 |
| customer_key | string \| null | 가맹점 고객키 |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true — 한 회원으로 합치지 말고 사람이 확인 |
| status | string | paid · unpaid · in_progress · canceled · unknown |
| status_name | string | 한글(결제 완료 · 미납 · 결제 진행 중 · 청구 취소 · 확인 필요 — 재청구하지 마십시오) |
| review_required | boolean | true 면 재청구 금지 |
| billed_amount | number | 청구 금액. 청구 취소면 0 |
| paid_amount | number | 결제 금액. paid 아니면 0 |
| unpaid_amount | number | 미납 금액. unpaid 일 때만 값 |
| attempts | number | 그 달 출금 시도 횟수(재출금 포함) |
| paid_date | string \| null | 결제일(paid 일 때만) |
| last_attempt_date | string \| null | 마지막 출금 시도일 |
| failure | object \| null | 마지막 실패. 미납·확인 필요·진행 중이면서 실패 시도가 있을 때 |
| failure.code | string \| null | 결제사 응답코드 |
| failure.category | string | 실패 분류(5장) |
| failure.auto_retry | boolean | true 면 자동 재결제 대상 — 재청구 금지 |

Billing 의 failure 에는 `retryable` 이 없습니다. 재청구 가능 여부는 category 로 판단(5장).

| status | 뜻 | 할 일 |
|---|---|---|
| paid | 결제 완료(재출금 성공 포함) | 없음 |
| unpaid | 그 달 시도 모두 실패 | failure 사유로 고객 안내 |
| in_progress | 출금 대기·처리 중 | 기다림 |
| canceled | 청구 취소 | 없음 |
| unknown | 결과 확정 불가 | 재청구 금지 |

### Payment — 출금 시도 한 건

| 필드 | 타입 | 설명 |
|---|---|---|
| transaction_id | string | 거래번호(고유) |
| account | string | 이용기관 코드 |
| customer_code | string | 고객코드 |
| customer_key | string \| null | 가맹점 고객키 |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true — 한 회원으로 합치지 말고 사람이 확인 |
| member_id | string \| null | 결제사(효성CMS) 회원 ID |
| payment_kind | string \| null | CMS(자동이체) · CARD 등 |
| contract_id | number \| null | 구독 계약번호 |
| payment_month | string \| null | 청구월 YYYYMM |
| payment_count | number \| null | 계약의 청구 회차 |
| payment_date | string \| null | 출금일 |
| amount | number \| null | 청구 금액 |
| paid_amount | number \| null | 받은 금액(succeeded 일 때만) |
| status | string | succeeded · failed · pending · processing · canceled · unknown |
| status_code | string | 내부 상태 코드(문의용) |
| review_required | boolean | true 면 재청구 금지 |
| failure | object \| null | 실패 정보 |
| failure.code | string \| null | 결제사 응답코드 |
| failure.category | string | 실패 분류 |
| failure.auto_retry | boolean | 자동 재결제 대상 |
| failure.retryable | boolean | 재청구로 성공 가능한 실패인지. review_required·auto_retry 면 항상 false |
| cancel_kind | string \| null | before_withdrawal(출금 전 취소) · approval_canceled(카드 승인 취소) · purchase_canceled(카드 매입 취소) |
| updated_at | string \| null | 원장 마지막 변경 시각 |

| status | 뜻 |
|---|---|
| succeeded | 성공 |
| failed | 실패 확정(사유 failure) |
| pending | 출금일 대기 |
| processing | 결제사 처리 중(자동이체는 보통 출금일 다음 영업일 확정) |
| canceled | 취소(cancel_kind) |
| unknown | 확정 불가, review_required=true, 재청구 금지 |

### Contract — 구독 계약

| 필드 | 타입 | 설명 |
|---|---|---|
| contract_id | number | 계약번호 |
| contract_no | string \| null | 계약(주문)번호 |
| account | string | 이용기관 코드 |
| customer_code | string | 고객코드 |
| customer_key | string \| null | 가맹점 고객키 |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true — 한 회원으로 합치지 말고 사람이 확인 |
| status | string | active · paused · terminated · (모르는 코드면 unknown) |
| status_name | string | 정상 · 일시 중지 · 중도 해지 · 잔액 완납 해지 · 만기 해지 · 계약 해지 |
| status_code | string | 내부 코드 |
| payment_type | string \| null | CMS · CARD 등 |
| payment_type_name | string \| null | 결제 방식 한글 |
| payment_day | number \| null | 매달 결제 약정일 1~31 |
| monthly_amount | number \| null | 계약 금액 |
| contract_start / contract_end | string \| null | 약정 시작·종료일 |
| contract_months | number \| null | 약정 기간(개월) |
| billing_start / billing_end | string \| null | 청구 시작·종료일 |
| billing_months | number \| null | 청구 횟수 |
| updated_at | string \| null | 마지막 변경 시각 |

### Customer — 고객

| 필드 | 타입 | 설명 |
|---|---|---|
| account | string | 이용기관 코드 |
| customer_code | string | 고객코드 |
| customer_key | string \| null | 가맹점 고객키 |
| customer_key_shared | boolean | 같은 고객키를 다른 고객도 쓰면 true — 한 회원으로 합치지 말고 사람이 확인 |
| in_use | boolean | 사용 중인 고객인지 |
| extra_fields | array | `{no, name, value}` — 추가 항목 1~15 중 이름 또는 값이 있는 것 |
| auto_debit | object | 자동이체 등록 상태 요약 |
| auto_debit.status | string | registered · pending · failed · deleted · none · unknown (결제수단 중 하나라도 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 / updated_at | string \| null | 등록·변경 시각 |

이름·연락처·주소·계좌·카드번호는 제공하지 않음. extra_fields 값은 가맹점 입력값 그대로(개인정보일 수 있음).

### Member — 결제수단

| 필드 | 타입 | 설명 |
|---|---|---|
| account | string | 이용기관 코드 |
| customer_code | string | 고객코드 |
| member_id | string \| null | 결제사 회원 ID |
| payment_kind | string \| null | 결제수단 |
| recurring | boolean | 정기결제 켜짐 |
| registration_status | string | registered · pending · failed · deleted · unknown |
| registration_status_code | string | 내부 코드 |
| updated_at | string \| null | 마지막 변경 시각 |

## 4. 엔드포인트 (모두 GET)

공통 목록 파라미터: `size` 1~100(기본 100), `cursor` = 앞 응답의 next_cursor.
공통 필터: `customer_key`(최대 200자, 고객키 설정 필요), `customer_code`, `contract_id`.

| 경로 | 권한 | 파라미터 | data |
|---|---|---|---|
| /v1/billings | payments:read | **month**(필수 YYYYMM), status(paid·unpaid·in_progress·canceled·unknown), customer_key, customer_code, contract_id, size, cursor | Billing[] |
| /v1/billings/summary | payments:read | **month**(필수), customer_key, customer_code, contract_id | `{month, total{count,billed_amount,paid_amount,unpaid_amount}, by_status[{status,status_name,count,billed_amount,paid_amount,unpaid_amount}], review_required}` |
| /v1/payments | payments:read | **from+to**(출금일, 최대 31일) 또는 **month** 중 하나(함께 쓰면 오류), status(succeeded·failed·pending·processing·canceled), customer_key, customer_code, contract_id, size, cursor | Payment[] |
| /v1/payments/summary | payments:read | **from**, **to**(필수, 최대 31일), customer_key, customer_code, contract_id | `{from, to, total{count,amount,paid_amount,review_required}, days[{date,count,amount,paid_amount,review_required,by_status{<status>:{status_name,count,amount,paid_amount}}}]}` — amount 는 재출금을 각각 더함. 월 미납은 /billings/summary 로 |
| /v1/payments/{transaction_id} | payments:read | — | Payment. 404 는 "없음"이 아님 |
| /v1/contracts | members:read | status(active·paused·terminated), customer_key, customer_code, size, cursor | Contract[] |
| /v1/contracts/{contract_id} | members:read | — | Contract |
| /v1/customers | members:read | customer_key, customer_code, size, cursor | Customer[] |
| /v1/customers/{customer_code} | members:read | — | Customer (여러 이용기관이면 Customer[]) |
| /v1/extra-fields | members:read | — | `[{account, customer_key_field(number\|null), fields[{no,name,required,options(string[]\|null)}]}]` |
| /v1/members | members:read | customer_code, size, cursor | Member[] |
| /v1/members/{customer_code} | members:read | — | `{customer_code, accounts[], payment_methods[Member 필드에서 customer_code 제외]}` |

## 5. 재청구 판단

차례로 확인하고 하나라도 걸리면 재청구하지 않습니다.

1. 503 → 재조회. 404 → 페이소프트 확인.
2. `review_required == true` → 금지.
3. status 가 실패(Billing `unpaid`, Payment `failed`)가 아니면 → 금지.
4. `failure.auto_retry == true` → 금지.
5. Payment: `failure.retryable` 이 true 일 때만 가능. Billing: `failure.category` 가 잔액부족·한도초과·일시장애 일 때만 가능.
6. 그 밖 → 고객 조치 후.

| category | 재청구 |
|---|---|
| 잔액부족 · 한도초과 · 일시장애 | 가능(고객 안내 후) |
| 계좌상태 · 카드상태 · 카드정보오류 · 본인확인 · 카드사확인 | 고객 조치 후 |
| 가맹점설정 · 요청오류 | 하지 않음(페이소프트 문의) |
| 기타오류 | 확인 후 |
| (모르는 값) | 고객 조치 후로 보수적으로 |

```python
RETRYABLE_CATEGORIES = {"잔액부족", "한도초과", "일시장애"}

def can_recharge(item):  # Billing 또는 Payment
    if item.get("review_required"):
        return False
    if item.get("status") not in ("unpaid", "failed"):
        return False
    f = item.get("failure") or {}
    if f.get("auto_retry"):
        return False
    if "retryable" in f:
        return f["retryable"]
    return f.get("category") in RETRYABLE_CATEGORIES
```

## 6. 테스트 환경(psk_test_ 키) 가상 데이터

어느 달을 조회해도 같은 시나리오. 이용기관 `TEST`, 고객 `C0001`~`C0005`, 고객키 = 추가 항목 1번 `MEM-1`~`MEM-5`.

| 계약 | 고객(고객키) | 결제수단·금액 | 매달 | Billing status | 계약 상태 |
|---|---|---|---|---|---|
| 1001 | C0001(MEM-1) | CMS 20,000 | 10일 성공 | paid | active |
| 1002 | C0002(MEM-2) | CMS 30,000 | 10일 잔액부족(Q301) 실패 → 15일 성공 | paid, attempts 2 | active |
| 1003 | C0003(MEM-3) | CMS 10,000 | 10일 실패인데 정상코드 | unknown, review_required | paused |
| 1004 | C0004(MEM-4) | CMS 20,000 | 출금 전 취소 | canceled | terminated |
| 1005 | C0005(MEM-5) | CMS 30,000 | 10일·15일 잔액부족 | unpaid | active |
| 1006 | C0001(MEM-1) | CARD 30,000 | 28일 출금 대기 | in_progress | active |

거래번호: `TEST<출금일 YYYYMMDD><계약번호 끝 3자리>` 예 `TEST20261010005`. 추가 항목: 1 "회원번호"(필수), 2 "회원 등급"(일반·VIP).

## 7. 최소 예제 (Python)

```python
import os, time, requests

BASE = "https://api.paysoft.co.kr/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['PAYSOFT_API_KEY']}"

def call(path, params=None, tries=4):
    for i in range(1, tries + 1):
        r = S.get(BASE + path, params=params, timeout=30)
        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 i < tries and wait <= 60:
            time.sleep(wait or 5)
            continue
        r.raise_for_status()

def iter_all(path, params):
    params = dict(params)
    while True:
        body = call(path, params)
        yield from body["data"]
        if not body["next_cursor"]:
            return
        params["cursor"] = body["next_cursor"]

for b in iter_all("/billings", {"month": "202610"}):
    print(b["customer_key"], b["contract_id"], b["status"], b["unpaid_amount"])
```
