솜씨 리셀러PARTNER PROGRAM

도움말 / 리셀러 API

리셀러 API

내 주문·고객 관리 시스템에서 지갑 조회, 솜씨 크레딧 지급, 웹호스팅 신청, 화이트라벨 고객 크레딧 배정을 자동으로 처리하는 REST API입니다. 대시보드에서 하는 것과 같은 규칙(단가, 잔액 확인, 원장 기록, 메일)으로 처리됩니다. 쇼츠·이미지·시리즈를 내 서비스에서 직접 만드는 연동은 솜씨 API v1을 쓰세요.

개요

기본 주소https://reseller.somssii.com/api/v1
인증Authorization: Bearer rsk_live_… (대시보드 계정 → API에서 발급)
형식요청·응답 모두 JSON, UTF-8. POST 본문은 Content-Type: application/json, 64KB 이하
응답성공 {"ok":true,"data":…} · 실패 {"ok":false,"error":{"code":"…","message":"…"}}
금액·수량금액은 원(정수), 크레딧은 정수. 요율은 bp(1bp = 0.01%, 1000 = 10%)
시각한국 시간(KST) YYYY-MM-DD HH:MM:SS, 시간대 표시 없음
범위키를 만든 리셀러 본인의 데이터만. 다른 리셀러의 번호를 넣으면 404
메서드경로권한설명
GET/meread내 계정, 적용 단가·요율, 잔액
GET/walletread지갑 잔액, 출금 가능액
GET/wallet/ledgerread지갑 원장
GET/referralread추천 링크, 클릭·가입, 수수료 합계
GET/commissionsread수수료 내역
GET/credits/lookup?email=read솜씨 회원 지급 가능 여부
POST/credits/transferscredits크레딧 지급(지갑 차감)
GET/credits/transfersread지급 내역
GET/credits/transfers/{id}read지급 한 건
GET/hosting/plansread판매 중인 요금제와 내 가격
GET/hosting/accountsread내 호스팅 계정 목록
GET/hosting/accounts/{id}read계정 한 건 + 최근 작업
POST/hosting/accountshosting호스팅 신청(첫 달 지갑 차감)
GET/wl/customersread내 스토어 고객
POST/wl/customers/{id}/creditswl고객 크레딧 지급·차감

키 발급·권한·인증

키 발급

  1. 대시보드 계정 → API의 리셀러 API 키에서 키 이름(1~60자, 예: "주문 서버")을 적고 필요한 권한만 골라 만듭니다.
  2. 키는 rsk_live_ + 소문자 16진수 40자(모두 49자)이며, 만든 직후 한 번만 보입니다. 바로 서버의 비밀 저장소(환경 변수 등)에 보관하세요. 이 사이트에는 키의 해시와 앞 16자만 남습니다.
  3. 활성 키는 최대 5개입니다. 목록에서 키별 마지막 사용 시각을 확인하고, 필요 없는 키는 [폐기]합니다. 폐기한 키는 바로 거절됩니다.

권한(scope)

권한할 수 있는 일
read모든 GET(조회). 아무 권한도 고르지 않으면 read만 갖습니다
creditsPOST /credits/transfers — 지갑에서 바로 차감됩니다
hostingPOST /hosting/accounts — 첫 달 요금이 지갑에서 바로 차감됩니다
wlPOST /wl/customers/{id}/credits

조회(GET)에는 언제나 read 권한이 필요합니다. 예를 들어 지급만 하고 결과를 조회하지 않는 서버라면 credits만, 지급 후 내역도 확인한다면 read와 credits를 함께 줍니다.

인증 헤더

Authorization: Bearer rsk_live_0123456789abcdef0123456789abcdef01234567
  • Authorization 헤더를 보낼 수 없는 환경에서는 X-Api-Key: rsk_live_…를 씁니다.
  • 쿠키·세션·CSRF 토큰은 쓰지 않습니다. 대시보드 로그인 쿠키가 함께 와도 키로만 인증합니다.
  • 계정이 승인 대기·정지·반려 상태면 키가 있어도 403 account_inactive입니다(키는 그대로 남고, 정지가 풀리면 다시 쓸 수 있습니다).

응답 형식·오류

// 성공
{ "ok": true, "data": { "balance": 1250000, "withdrawable": 32000 } }

// 실패 — 입력 오류는 fields 에 필드별 문구
{ "ok": false, "error": { "code": "validation", "message": "지급 크레딧(credits)을 양의 정수로 보내세요.",
                          "fields": { "credits": "지급 크레딧(credits)을 양의 정수로 보내세요." } } }
HTTPerror.code뜻·대응
200—성공(조회, 또는 같은 client_token 재전송에 대한 원래 결과)
201—새로 처리됨(지급·신청·배정)
400bad_requestJSON 객체 본문이 없거나 형식이 틀림(Content-Type 확인, 64KB 이하)
401unauthorized키를 보내지 않음
401invalid_key없는 키, 형식이 틀린 키, 폐기된 키
403scope_denied그 요청에 필요한 권한이 키에 없음
403account_inactive리셀러 계정이 이용 중이 아님
404not_found없는 경로, 없는 번호, 다른 리셀러의 번호
405method_not_allowed그 경로에 없는 메서드(Allow 헤더 참고)
409conflict같은 client_token으로 다른 내용을 보냄 → 새 요청에는 새 토큰
422validation입력값·업무 규칙 오류. message(와 fields)를 그대로 보여 주면 됩니다
422insufficient_balance지갑 잔액 부족(크레딧 지급·호스팅), 또는 고객 크레딧보다 많이 차감
429rate_limited요청 제한 초과 → Retry-After(초) 뒤에 다시
500server_error서버 오류. error.request_id를 함께 문의
503unavailable지금 받지 않는 기능(예: 호스팅 신청 중단) 또는 점검 중

error.code로 분기하고, 사용자에게는 한국어 message를 보여 주세요. 쓰기 요청이 시간 초과·5xx로 끝났다면 같은 client_token으로 다시 보내면 안전합니다(중복 방지).

요청 제한

  • 키마다 분당 120회(모든 요청), 그 가운데 쓰기(POST)는 분당 10회입니다.
  • 솜씨 회원 조회(GET /credits/lookup)는 대시보드와 같은 한도(리셀러당 10분에 30회)를 함께 씁니다. 지급 요청에서 회원 확인에 실패한 경우도 이 한도에 더해집니다.
  • 넘으면 429 rate_limited와 Retry-After 헤더(초), error.retry_after가 옵니다. 그 시간만큼 기다린 뒤 다시 보내세요.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "ok": false, "error": { "code": "rate_limited", "message": "쓰기 요청이 너무 많습니다(키당 분당 10회). 잠시 후 다시 시도하세요.", "retry_after": 37 } }

중복 방지(client_token)

모든 POST 요청에는 client_token이 필요합니다. 네트워크 오류로 같은 요청을 다시 보내도 지급·신청·배정은 한 번만 처리됩니다.

  • 형식: 영문·숫자·_·- 16~64자. 예: bin2hex(random_bytes(16)), UUID, order-1042-credit
  • 새 요청마다 새 값을 만들고, 보내기 전에 내 주문 기록에 함께 저장하세요. 재시도할 때는 저장해 둔 같은 값을 보냅니다.
  • 이미 처리된 토큰을 같은 내용으로 다시 보내면 처리하지 않고 원래 결과를 200, "replayed": true로 돌려줍니다.
  • 같은 토큰으로 다른 내용(다른 회원·수량·고객·아이디 등)을 보내면 409 conflict입니다.
  • 토큰은 리셀러 단위로 구분됩니다(키를 바꿔도 같은 토큰은 같은 요청으로 봅니다).

목록·페이지

목록 조회는 ?page=1&per_page=50을 받습니다. per_page는 1~100(기본 50)이고, 최신순입니다. 마지막 쪽보다 큰 page를 주면 마지막 쪽을 돌려줍니다.

{ "ok": true, "data": { "items": [ … ], "page": 1, "per_page": 50, "total": 128 } }

계정·지갑

GET/me권한 read

내 계정 정보와 지금 적용되는 단가·요율, 지갑 잔액, 이 키의 정보입니다. 연동 점검용 첫 호출로 쓰세요.

{
  "ok": true,
  "data": {
    "id": 12,
    "company": "(주)예시상사",
    "status": "active",
    "ref_code": "ab3kq7xz",
    "rates": { "credit_unit_price": 90, "commission_rate_bp": 1000, "hosting_discount_bp": 500 },
    "balance": 1250000,
    "somssii_linked": true,
    "key": { "prefix": "rsk_live_0123456", "label": "주문 서버", "scopes": ["read", "credits"] }
  }
}

credit_unit_price는 크레딧 1개당 원, commission_rate_bp·hosting_discount_bp는 bp입니다(위 예시 값: 10%, 5%).

GET/wallet권한 read
{ "ok": true, "data": { "balance": 1250000, "withdrawable": 32000 } }

withdrawable은 지금 출금 신청할 수 있는 금액입니다(출금 규칙). 출금 신청 자체는 대시보드에서 합니다.

GET/wallet/ledger권한 read

지갑 원장입니다. 검색 조건: kind(아래 구분), from·to(YYYY-MM-DD), page, per_page.

GET /wallet/ledger?kind=credit_transfer&from=2026-10-01&to=2026-10-31&per_page=20

{ "ok": true, "data": { "items": [
    { "id": 5531, "kind": "credit_transfer", "amount": -45000, "balance_after": 1205000,
      "ref_type": "credit_transfer", "ref_id": 881, "memo": "솜씨 회원 cu****@example.com 500크레딧",
      "created_at": "2026-10-02 10:15:03" }
  ], "page": 1, "per_page": 20, "total": 1 } }
kind구분kind구분
topup충전topup_cancel충전 취소
credit_transfer크레딧 지급credit_reversal크레딧 지급 취소
commission추천 수수료(회수는 음수)admin_adjust관리자 조정
withdrawal출금withdrawal_refund출금 반려·취소
hosting_charge호스팅 요금hosting_refund호스팅 환불
domain_charge도메인 구매(견적 결제)domain_refund도메인 환불(결제 뒤 반려)
wl_agency_share운영대행 수익쉐어(플랫폼 몫)
GET/referral권한 read
{ "ok": true, "data": {
    "ref_code": "ab3kq7xz",
    "ref_link": "https://reseller.somssii.com/r/ab3kq7xz",
    "clicks_30d": 214,
    "signups": 37,
    "commission": { "rate_bp": 1000, "hold_days": 14, "held": 12000, "credited": 86500 }
} }

clicks_30d는 최근 30일 클릭, signups는 누적 추천 가입자, held는 보류 중 수수료 합계, credited는 적립 수수료 누적(회수 반영)입니다.

GET/commissions권한 read
{ "ok": true, "data": { "items": [
    { "id": 301, "order_ref": "…a1b2c3", "approved_at": "2026-09-20 14:02:11",
      "gross_amount": 33000, "cancel_amount": 0, "base_amount": 30000, "rate_bp": 1000,
      "target_amount": 3000, "credited_amount": 0, "status": "held", "hold_until": "2026-10-04 14:02:11" }
  ], "page": 1, "per_page": 50, "total": 1 } }

status: held(보류) · credited(적립) · void(무효). target_amount는 현재 계산된 수수료, credited_amount는 지갑에 실제로 반영된 금액입니다. 계산 방법은 추천 수수료를 보세요.

크레딧 판매

GET/credits/lookup?email=권한 read

솜씨 회원에게 지급할 수 있는지 미리 확인합니다. 이메일은 정확히 같아야 합니다. 가입하지 않았거나 지급 대상이 아닌 회원은 같은 답(eligible: false)을 돌려줍니다.

GET /credits/lookup?email=customer%40example.com

{ "ok": true, "data": { "eligible": true, "email_masked": "cu******@example.com", "name_masked": "홍*동",
                        "unit_price": 90, "min": 10, "max": 100000 } }

{ "ok": true, "data": { "eligible": false, "message": "솜씨에 가입된 활성 회원이 아닙니다." } }

이메일 형식이 틀리면 422 validation(fields.email)입니다. 이 조회는 리셀러당 10분에 30회까지입니다.

POST/credits/transfers권한 credits

솜씨 회원에게 크레딧을 지급하고 지갑에서 credits × 내 단가를 차감합니다. 대시보드 지급과 같은 처리이며, 지급 완료 메일이 나에게 갑니다(회원에게는 메일 없음).

필드형식설명
email문자열, 필수받을 솜씨 회원의 가입 이메일(이용 중인 일반 회원)
credits정수, 필수지급 크레딧. 1회 10 ~ 100,000
client_token문자열, 필수중복 방지 토큰
memo문자열, 선택100자 이내. 지급 내역에 남습니다
POST /credits/transfers
{ "email": "customer@example.com", "credits": 500, "client_token": "6f1c0d2a9b8e4f7a8c3d2e1f0a9b8c7d", "memo": "주문 #1042" }

HTTP/1.1 201 Created
{ "ok": true, "data": {
    "transfer": { "id": 881, "status": "done", "email_masked": "cu******@example.com", "credits": 500,
                  "unit_price": 90, "amount": 45000, "memo": "주문 #1042",
                  "created_at": "2026-10-02 10:15:03", "reversed_at": null },
    "balance_after": 1205000,
    "replayed": false
} }
  • 지급 대상이 아니면 422 validation(fields.email), 수량이 범위를 벗어나면 422 validation, 잔액이 부족하면 422 insufficient_balance입니다. 실패한 요청은 아무것도 차감하지 않습니다.
  • 같은 client_token 재전송은 200과 "replayed": true, 원래 지급 정보입니다. 이때 balance_after는 원래 지급 직후의 잔액입니다.
  • 지급 취소는 API로 할 수 없습니다. 고객센터에 지급 번호(transfer.id)로 요청하세요(취소 조건).
  • 지급할 크레딧은 고객이 살 상품에 맞춰 계산합니다: 솜씨 쇼츠 1편 25 · 솜씨 이미지 1세트 10 · 솜씨 시리즈 페이지당 1(안내용 기준값 — 크레딧 계산표). 예: 쇼츠 4편 주문 → "credits": 100.
GET/credits/transfers권한 read

내 지급 내역(최신순). 검색 조건: status(done 완료 · reversed 취소됨), page, per_page. 각 항목은 위 transfer와 같은 모양입니다.

GET /credits/transfers?status=done&per_page=2

{ "ok": true, "data": { "items": [
    { "id": 881, "status": "done", "email_masked": "cu******@example.com", "credits": 500, "unit_price": 90,
      "amount": 45000, "memo": "주문 #1042", "created_at": "2026-10-02 10:15:03", "reversed_at": null },
    { "id": 874, "status": "done", "email_masked": "le***@example.com", "credits": 100, "unit_price": 90,
      "amount": 9000, "memo": "", "created_at": "2026-10-01 16:40:51", "reversed_at": null }
  ], "page": 1, "per_page": 2, "total": 15 } }
GET/credits/transfers/{id}권한 read
{ "ok": true, "data": { "id": 881, "status": "reversed", "email_masked": "cu******@example.com", "credits": 500,
                        "unit_price": 90, "amount": 45000, "memo": "주문 #1042",
                        "created_at": "2026-10-02 10:15:03", "reversed_at": "2026-10-02 11:40:00" } }

웹호스팅

GET/hosting/plans권한 read

판매 중인 요금제와 내 할인율을 반영한 월 요금(price)입니다. enabled가 false면 지금은 신청을 받지 않습니다.

{ "ok": true, "data": {
    "enabled": true,
    "hosting_discount_bp": 500,
    "items": [
      { "id": 2, "code": "basic", "name": "베이직", "description": "소규모 사이트용",
        "list_price": 20000, "price": 19000, "disk_quota_mb": 10240, "php_max_children": 5 }
    ]
} }

disk_quota_mb는 안내용 값이고, php_max_children는 PHP 동시 처리 수입니다.

GET/hosting/accounts권한 read

내 호스팅 계정 목록(최신순, page·per_page). 항목 모양은 아래 상세와 같고 jobs가 없습니다. 목록에서는 plan_code가 null일 수 있으니 plan_id를 쓰세요.

GET /hosting/accounts?per_page=1

{ "ok": true, "data": { "items": [
    { "id": 57, "plan_id": 2, "plan_code": null, "username": "shop1042", "domain": "shop1042.example.com",
      "with_www": false, "status": "pending_approval", "status_label": "승인 대기", "monthly_price": 19000,
      "auto_renew": true, "paid_until": null, "grace_until": null, "suspend_reason": null,
      "ssl_status": "none", "ssl_names": [], "cert_not_after": null, "ssl_message": null, "reject_reason": null,
      "created_at": "2026-10-02 10:20:00", "provisioned_at": null, "deleted_at": null }
  ], "page": 1, "per_page": 1, "total": 3 } }
GET/hosting/accounts/{id}권한 read
{ "ok": true, "data": {
    "id": 57, "plan_id": 2, "plan_code": "basic", "username": "shop1042", "domain": "shop1042.example.com",
    "with_www": false, "status": "active", "status_label": "이용 중", "monthly_price": 19000, "auto_renew": true,
    "paid_until": "2026-11-02 10:20:00", "grace_until": null, "suspend_reason": null,
    "ssl_status": "issued", "ssl_names": ["shop1042.example.com"], "cert_not_after": "2026-12-31 09:00:00",
    "ssl_message": null, "reject_reason": null,
    "created_at": "2026-10-02 10:20:00", "provisioned_at": "2026-10-02 10:24:31", "deleted_at": null,
    "jobs": [
      { "id": 910, "type": "host_create", "type_label": "호스팅 생성", "status": "done", "status_label": "완료",
        "result": "ok", "result_label": "성공", "created_at": "2026-10-02 10:22:05", "finished_at": "2026-10-02 10:24:31" }
    ]
} }
필드값
statuspending_approval 승인 대기 · provisioning 생성 중 · active 이용 중 · grace 미납 유예 · suspending 정지 처리 중 · suspended 정지됨 · unsuspending 재개 처리 중 · deleting 삭제 중 · deleted 삭제됨 · failed 실패 · rejected 반려
suspend_reasonnonpayment 미납 · admin 관리자 · null
ssl_statusnone 없음 · dns_pending DNS 대기 · issued 발급됨 · failed 실패. 대기·실패일 때 ssl_message에 안내 문구
paid_until다음 결제일(이용 기간 끝). grace_until은 유예 종료 시각
jobs[]최근 작업 20건(유형·상태·결과 라벨만)

비밀번호는 어떤 응답에도 나오지 않습니다. 연장·자동 연장 설정·비밀번호 변경·SSL 재시도는 대시보드에서 합니다(호스팅 매뉴얼).

POST/hosting/accounts권한 hosting

호스팅을 신청하고 첫 달 요금(price)을 지갑에서 차감합니다. 대시보드 신청과 같은 검사·결제·승인 절차를 거칩니다.

필드형식설명
plan_id정수, 필수/hosting/plans의 id
username문자열, 필수영문 소문자로 시작, 영문 소문자·숫자 3~16자. 예약어 불가, 변경 불가
domain문자열, 필수example.com 형식(스킴·경로 없이). 다른 계정·리셀러가 쓰는 도메인과 그 상·하위 도메인 불가
with_www불리언, 선택www.도 함께 서비스. 대표 도메인(예: example.com, example.co.kr)만
password문자열, 필수공백 없는 ASCII 10~64자, 영문+숫자 포함, 아이디 포함 불가. 리눅스·JupyterLab·DB 공통이며 응답·로그에 남지 않습니다
client_token문자열, 필수중복 방지 토큰
POST /hosting/accounts
{ "plan_id": 2, "username": "shop1042", "domain": "shop1042.example.com", "with_www": false,
  "password": "S3cure-Example-Pass", "client_token": "host-order-1042-2f9a1c" }

HTTP/1.1 201 Created
{ "ok": true, "data": {
    "account": { "id": 57, "plan_id": 2, "plan_code": "basic", "username": "shop1042",
                 "domain": "shop1042.example.com", "status": "pending_approval", "status_label": "승인 대기",
                 "monthly_price": 19000, "auto_renew": true, "paid_until": null, "…": "…" },
    "price": 19000,
    "balance_after": 1186000,
    "replayed": false
} }
  • 새 계정은 보통 pending_approval(관리자 승인 후 생성)로 시작합니다. GET /hosting/accounts/{id}로 active가 될 때까지 몇 분 간격으로 확인하세요. 반려되면 rejected가 되고 첫 달 요금이 지갑으로 돌아옵니다.
  • 입력 오류는 422 validation과 fields(plan_id·username·domain·password), 잔액 부족은 422 insufficient_balance, 신청 중단은 503 unavailable입니다.
  • 같은 client_token을 같은 요금제·아이디·도메인·www로 다시 보내면 원래 계정(200, "replayed": true)을 돌려줍니다. 비밀번호는 비교하지 않습니다.
  • 도메인 DNS에 A 레코드 58.121.85.179를 미리 추가해 두면 생성 직후 SSL이 발급됩니다(DNS·SSL).

화이트라벨 고객

GET/wl/customers권한 read
{ "ok": true, "data": { "items": [
    { "id": 77, "email_masked": "bu****@example.com", "name": "김고객", "balance": 40,
      "status": "active", "last_login_at": "2026-10-01 18:22:40", "created_at": "2026-09-28 09:00:12" }
  ], "page": 1, "per_page": 50, "total": 1 } }

balance는 고객의 스토어 크레딧, status는 active(이용 중) 또는 suspended(정지)입니다. 고객 등록·비밀번호·정지는 대시보드에서 합니다.

POST/wl/customers/{id}/credits권한 wl

내 스토어 고객의 크레딧을 지급(양수)하거나 차감(음수)합니다. 대시보드의 "크레딧 지급·차감"과 같은 처리이며, 지갑에서는 아무것도 빠지지 않습니다(생성 비용은 고객이 실제로 만들 때 연결한 솜씨 계정에서 나갑니다).

필드형식설명
amount정수, 필수0이 아닌 ±1,000,000 이내. 음수 = 차감(고객 보유량까지만)
client_token문자열, 필수중복 방지 토큰
memo문자열, 선택300자까지 저장. 고객 크레딧 내역에 보입니다
POST /wl/customers/77/credits
{ "amount": 100, "client_token": "wl-77-topup-20261002-01", "memo": "10월 이용권" }

HTTP/1.1 201 Created
{ "ok": true, "data": {
    "entry": { "id": 4410, "customer_id": 77, "delta": 100, "balance_after": 140,
               "memo": "10월 이용권", "created_at": "2026-10-02 10:30:00" },
    "customer": { "id": 77, "balance": 140 },
    "replayed": false
} }
  • 다른 리셀러의 고객 번호는 404, 보유량보다 많이 차감하면 422 insufficient_balance입니다.
  • 같은 client_token 재전송은 200, "replayed": true와 원래 기록, 그리고 고객의 현재 잔액을 돌려줍니다.

예제(curl · PHP)

curl

# 키는 환경 변수에 둔다(예시 값)
export RS_KEY="rsk_live_0123456789abcdef0123456789abcdef01234567"
BASE="https://reseller.somssii.com/api/v1"

# 연동 점검
curl -s "$BASE/me" -H "Authorization: Bearer $RS_KEY"

# 잔액 · 출금 가능액
curl -s "$BASE/wallet" -H "Authorization: Bearer $RS_KEY"

# 이번 달 크레딧 지급 원장
curl -s "$BASE/wallet/ledger?kind=credit_transfer&from=2026-10-01&to=2026-10-31&per_page=100" \
  -H "Authorization: Bearer $RS_KEY"

# 지급 전 회원 확인 (@ 는 %40 으로)
curl -s "$BASE/credits/lookup?email=customer%40example.com" -H "Authorization: Bearer $RS_KEY"

# 크레딧 지급 — client_token 은 주문 기록에 먼저 저장하고, 재시도에도 같은 값을 쓴다
TOKEN=$(openssl rand -hex 16)
curl -s -X POST "$BASE/credits/transfers" \
  -H "Authorization: Bearer $RS_KEY" -H "Content-Type: application/json" \
  -d "{\"email\":\"customer@example.com\",\"credits\":500,\"client_token\":\"$TOKEN\",\"memo\":\"주문 #1042\"}"

# 요금제 확인 후 호스팅 신청
curl -s "$BASE/hosting/plans" -H "Authorization: Bearer $RS_KEY"
curl -s -X POST "$BASE/hosting/accounts" \
  -H "Authorization: Bearer $RS_KEY" -H "Content-Type: application/json" \
  -d '{"plan_id":2,"username":"shop1042","domain":"shop1042.example.com","password":"S3cure-Example-Pass","client_token":"host-order-1042-2f9a1c"}'

# 생성 완료 확인
curl -s "$BASE/hosting/accounts/57" -H "Authorization: Bearer $RS_KEY"

# 화이트라벨 고객에게 100 크레딧
curl -s -X POST "$BASE/wl/customers/77/credits" \
  -H "Authorization: Bearer $RS_KEY" -H "Content-Type: application/json" \
  -d '{"amount":100,"client_token":"wl-77-topup-20261002-01","memo":"10월 이용권"}'

PHP — 재시도에 안전한 작은 클라이언트

<?php
// 키는 코드에 적지 말고 환경 변수·비밀 저장소에서 읽는다.
final class ResellerApiError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly string $errorCode,
                                string $message, public readonly array $fields = [])
    {
        parent::__construct($message);
    }
}

final class ResellerApi
{
    public function __construct(private string $key, private string $base = "https://reseller.somssii.com/api/v1") {}

    public function get(string $path, array $query = []): array
    {
        return $this->call("GET", $path . ($query !== [] ? "?" . http_build_query($query) : ""));
    }

    /** 쓰기 — $body 에 client_token 을 넣는다. 같은 토큰이면 몇 번 보내도 한 번만 처리된다. */
    public function post(string $path, array $body): array
    {
        return $this->call("POST", $path, $body);
    }

    private function call(string $method, string $path, ?array $body = null): array
    {
        for ($try = 1; ; $try++) {
            $ch = curl_init($this->base . $path);
            $headers = ["Authorization: Bearer " . $this->key, "Accept: application/json"];
            if ($body !== null) {
                $headers[] = "Content-Type: application/json";
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
            }
            curl_setopt_array($ch, [
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_CONNECTTIMEOUT => 10,
                CURLOPT_TIMEOUT        => 30,
            ]);
            $raw    = curl_exec($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            curl_close($ch);

            $json = is_string($raw) ? json_decode($raw, true) : null;

            // 네트워크 오류·5xx: 같은 요청(같은 client_token)으로 다시 — 중복 처리되지 않는다
            if (($raw === false || $status >= 500) && $try < 3) {
                sleep(2 * $try);
                continue;
            }
            // 요청 제한: 알려 준 시간만큼 기다렸다가 다시
            if ($status === 429 && $try < 3) {
                sleep(max(1, (int) ($json["error"]["retry_after"] ?? 5)));
                continue;
            }
            if (!is_array($json)) {
                throw new ResellerApiError($status, "transport", "응답을 해석할 수 없습니다(HTTP {$status}).");
            }
            if (($json["ok"] ?? false) !== true) {
                $e = $json["error"] ?? [];
                throw new ResellerApiError($status, (string) ($e["code"] ?? "error"), (string) ($e["message"] ?? ""), (array) ($e["fields"] ?? []));
            }
            return $json["data"];
        }
    }
}

// ── 사용 예: 주문 1042 에 대해 솜씨 크레딧 500 지급 ──
$api = new ResellerApi((string) getenv("RS_API_KEY"));

$token = bin2hex(random_bytes(16));      // 새 요청마다 새 값
// saveOrderToken(1042, $token);         // 보내기 전에 내 주문 기록에 저장 → 재시도·재실행 때 같은 값을 쓴다

try {
    $check = $api->get("/credits/lookup", ["email" => "customer@example.com"]);
    if (!$check["eligible"]) {
        exit("지급할 수 없는 회원입니다: " . $check["message"] . "\n");
    }
    $res = $api->post("/credits/transfers", [
        "email"        => "customer@example.com",
        "credits"      => 500,
        "client_token" => $token,
        "memo"         => "주문 #1042",
    ]);
    printf("지급 #%d, 차감 %d원, 잔액 %s원%s\n",
        $res["transfer"]["id"], $res["transfer"]["amount"], $res["balance_after"] ?? "-",
        $res["replayed"] ? " (이미 처리된 요청)" : "");
} catch (ResellerApiError $e) {
    // insufficient_balance → 지갑 충전 안내, validation → $e->fields 표시, conflict → 새 토큰으로 새 요청
    fwrite(STDERR, "[{$e->status} {$e->errorCode}] {$e->getMessage()}\n");
}

보안 수칙

  • 키는 서버에서만 쓰세요. 브라우저·모바일 앱·공개 저장소(Git)에 넣으면 누구나 내 지갑으로 지급·신청할 수 있습니다.
  • 용도별로 키를 나누고 필요한 권한만 주세요(조회 전용 서버에는 read만).
  • 키를 이메일·메신저로 주고받지 마세요. 노출이 의심되면 바로 폐기하고 새로 만드세요. 대시보드의 마지막 사용 시각으로 이상 사용을 확인할 수 있습니다.
  • 호스팅 비밀번호는 HTTPS로만 보내고, 내 시스템 로그에도 남기지 마세요.
  • 쓰기 요청의 client_token을 주문과 함께 저장해 두면, 장애 뒤 다시 실행해도 이중 지급·이중 신청이 생기지 않습니다.