EGG PAY

가맹점 API 설명서

가맹점 사이트에 EGG PAY 를 붙이면, 사용자가 결제한 금액만큼 USDE 가 가맹점 지갑으로 전송됩니다.

1. 시작하기

  1. 가맹점 신청을 하면 검토 후 알려 드립니다.
  2. 승인되면 가맹점 코드와 API 키를 드립니다.
  3. 사용자가 지갑 연동을 마쳐야 결제할 수 있습니다 (3절).
  4. 사용자가 거래소 회원이어야 결제할 수 있습니다. 연동 확인의 exchange.ready 로 보실 수 있습니다 (3절).

결제 순서

  1. 가맹점 사이트에서 충전 요청 — 가맹점 서버가 결제 요청을 부릅니다 (5절)
  2. 로그인 (회원이 아니면 회원가입) — 방식마다 다릅니다 (5절)
  3. 구매 등록
  4. 매칭
  5. 지갑 앱으로 이동
  6. 서명
  7. 컨트랙트 시작 — 끝나면 결과를 보내 드립니다 (6절)

가맹점이 하시는 곳은 결제 요청, API 만 방식의 구매 등록, 그리고 결과 받기(웹훅 · 상태 조회)입니다.

가맹점이 만드시는 것 — 충전 금액 입력창과 그 안의 환율·수수료 계산, 그리고 입금 내역을 확인하는 관리자 페이지입니다. 결제마다 방식(mode)도 골라 주십시오 (5절). API 만 방식(api)이면 사용자가 보는 화면도 가맹점이 만드십니다 — 「지갑 앱에서 승인하세요」 안내, [구매] 버튼, 매칭 대기와 [취소] 버튼, 「지갑 앱에서 서명하세요」 안내. 거래소 회원이 아닌 사용자에게는 거래소 가입을 안내해 주십시오.

2. 인증

모든 요청에 두 헤더를 넣습니다.

헤더무엇
X-EggPay-Merchant-Id가맹점 코드
X-EggPay-Api-Key비밀 키
API 키를 브라우저에 내려보내지 마십시오. 가맹점 서버에서만 쓰십시오. 브라우저에 두면 누구나 그 키로 우리 API 를 부를 수 있습니다.

키는 둘입니다. API 키는 가맹점이 우리를 부를 때, 웹훅 비밀키는 우리가 가맹점을 부를 때 씁니다 (6절).

호출 한도 (가맹점 하나당, 분당)

API분당세는 창구
결제 요청60결제 요청 · 구매 등록
결제 상태 조회600결제 상태 조회
지갑 연동60연동 시작 · 연동 확인 · 연동 해제
전체 합1,200모든 창구의 합. 지갑 잔액 조회와 매칭 전 취소는 이 몫만 셉니다

표의 값은 기본값입니다. 넘으면 429 와 함께 retryAfterSeconds 를 돌려드립니다.

공통 모양

무엇모양
오류{ "error": { "code": "…", "message": "…" } } 와 HTTP 상태. code 로 가르십시오. 429 에는 retryAfterSeconds 도 붙습니다 (10절)
금액소수 둘째 자리까지의 문자열입니다 (예 "12.50"). 셋째 자리가 오면 400 입니다 — 버리거나 반올림하지 않습니다
시각ISO-8601 UTC 문자열입니다 (예 2026-09-28T03:04:05.123Z). 웹훅 헤더 X-EggPay-Timestamp 만 초입니다 (6절)
모르는 필드보내시면 400 invalid_request 입니다
다시 보내기결제 요청에는 가맹점이 정하는 키 merchantRequestId 를 넣습니다. 답을 못 받으셨으면 같은 키, 같은 내용으로 다시 보내 주십시오 — 처음 결과를 드립니다. 같은 키에 다른 내용이면 409 conflict 입니다

3. 지갑 연동

사용자가 SJO 지갑앱을 설치하고, 계정 주소를 만들고, 가맹점 회원 아이디에 그 주소를 연동해 두어야 결제할 수 있습니다.

연동 시작

POST /api/v1/merchant/wallet-links
필드무엇
merchantUserId가맹점 사이트의 회원 아이디 (1~64자)
walletAddress사용자가 입력한 SJO 지갑 주소
{ "linkId": "…", "status": "PENDING", "expiresAt": "…" }

이 뒤로 사용자의 지갑앱에 알림이 가고, 사용자가 지갑앱에서 서명으로 승인하면 연동이 끝납니다. 옮겨 칠 숫자는 없습니다. 요청은 5분 동안 살아 있습니다 (expiresAt). 같은 회원·같은 주소로 다시 부르시면 살아 있는 요청을 그대로 드립니다. 같은 회원·같은 주소가 이미 연동돼 있으면 새 요청을 만들지 않고 그 연동을 "status": "LINKED" 로 드립니다.

한 가맹점 안에서 지갑 하나는 회원 하나에만 연동됩니다. 그 회원이 이미 다른 주소로, 또는 그 주소가 다른 회원으로 연동돼 있으면 409 wallet_link_conflict 입니다. 살아 있는 요청(5분 안)도 같습니다 — 그 회원의 다른 주소 요청, 또는 그 주소의 다른 회원 요청이 살아 있으면 409 wallet_link_conflict 입니다. 5분이 지난 요청은 막지 않습니다. 바꾸시려면 연동은 연동 해제 뒤, 요청은 expiresAt 이 지난 뒤 다시 시작해 주십시오. 그 주소의 지갑 사용자가 없으면 422 wallet_undeliverable 입니다.

연동 확인

GET /api/v1/merchant/wallet-links/{merchantUserId}

결제 버튼을 그리기 전에 부르십시오. 연동되고 거래소 준비가 된 사용자만 결제할 수 있습니다.

{ "linked": true, "status": "LINKED", "walletAddress": "sjo1…", "linkedAt": "…",
  "exchange": { "ready": true, "reason": null } }
필드무엇
linkedstatus 가 LINKED 면 true
statusLINKED(연동됨) · PENDING(승인 기다림) · EXPIRED(5분 지남) · REJECTED(거절됨) · NONE(지금 연동 없음 — 연동한 적이 없거나 연동 해제함)
walletAddress · linkedAtLINKED 일 때만. 그 밖에는 null
exchangeLINKED 일 때만. ready: true 여야 그 주소로 결제를 받습니다. false 면 reason 에 거래소가 준 까닭(회원 아님 · 본인 확인 전 · 가입 승인 대기 · 정지 등)이 옵니다. 거래소에 닿지 못했으면 exchange 가 null 입니다 — 잠시 뒤 다시 조회해 주십시오

이름 · 거래소 아이디 같은 개인정보는 싣지 않습니다.

연동 해제

DELETE /api/v1/merchant/wallet-links/{merchantUserId}
{ "linked": false }

그 지갑으로 진행 중인 결제가 있으면 409 payment_in_flight 입니다. 연동된 것이 없으면 404 입니다. 새 주소는 연동 시작부터 다시 해 주십시오.

4. 환율

GET /api/v1/rates

키가 필요 없습니다. 사용자가 금액을 치는 동안 브라우저에서 바로 불러 환산해 보여주실 수 있습니다. IP 하나당 분당 120회입니다 (기본값).

{ "base": "USDE", "quote": "…", "rate": …, "asOf": "…" }
이 창구는 재단 환율을 그대로 드립니다. 가맹점은 이 환율을 쓰셔야 합니다.

환율을 지금 확인할 수 없을 때는 503 과 rate_unavailable 을 드립니다. 이전 값을 대신 드리지 않습니다 — 잠시 후 다시 불러 주십시오.

{ "error": { "code": "rate_unavailable", "message": "…" } }

5. 결제

방식 셋

결제를 요청할 때 mode 로 고르십니다. 로그인과 구매 등록만 다르고, 매칭부터는 같습니다.

mode화면로그인구매 등록
apiAPI 만 — 가맹점 화면사용자가 지갑 앱에서 승인 가맹점 서버가 구매 등록을 부릅니다
popup_wallet팝업사용자가 지갑 앱에서 승인 사용자가 팝업에서
popup_password팝업거래소 아이디 · 비밀번호 + OTP 사용자가 팝업에서

결제 요청

POST /api/v1/payments
필드무엇
merchantRequestId가맹점이 정하는 다시 보내기 키. 가맹점마다 유일. 1~64자, 영문 · 숫자 · _ - . :
buyerWalletAddress사용자의 지갑 주소 — 이 가맹점에 연동돼 있어야 합니다
merchantWalletAddress가맹점의 지갑 주소 — 등록하신 지급 주소와 같아야 합니다
amountUsde구매할 USDE 수량, 소수 둘째 자리까지 (예 "12.50")
modeapi · popup_wallet · popup_password
{ "paymentId": "pay_…", "status": "REQUESTED", "statusReason": null,
  "popupUrl": "https://…", "created": true }
필드무엇
statusapi 는 TRADING — 거래소 회원이 아니면 곧바로 CANCELLED 입니다. 팝업 둘은 REQUESTED(요청 기록)
statusReasonCANCELLED 일 때 까닭 (7절)
popupUrl팝업 둘만. 아직 준비되지 않았으면 null — 결제 상태 조회로 다시 읽어 주십시오. api 는 늘 null
created이 호출로 처음 만들었으면 true, 같은 merchantRequestId 의 처음 결과면 false

필드가 하나라도 없거나 잘못되면 거절합니다 (10절). 그때는 가맹점 화면에서 경고를 띄워 주십시오.

팝업 열기

popup_wallet · popup_password 만 해당합니다.

window.open(popupUrl, '_blank', 'noopener,noreferrer')

새 창으로 여십시오. 틀(iframe) 안에 넣지 마십시오 — 팝업은 거래소 화면입니다. PC 와 모바일 둘 다 됩니다.

사용자가결제는
구매 등록 전에 팝업을 닫음요청 기록 그대로입니다. 같은 popupUrl 로 다시 여시거나 매칭 전 취소로 닫으십시오
구매 화면에서 [Cancel]거래취소 (request_closed)
구매 등록 뒤, 매칭 전에 진행 화면을 닫음거래취소 (popup_closed)
popupUrl 은 받은 그대로 여십시오. 수량은 저희가 들고 있는 값을 씁니다 — 브라우저가 금액을 들고 있으면 사용자가 고칠 수 있습니다.

구매 등록

api 만 해당합니다.

POST /api/v1/payments/{paymentId}/purchase

본문은 없습니다. 사용자가 지갑 앱에서 로그인을 승인한 뒤 5분 안에 가맹점 화면의 [구매] 를 누르면 부르십시오. 409 login_required 면 아직 승인 전입니다 — 잠시 뒤 다시 부르십시오. 승인 뒤 5분 안에 [구매] 가 없으면 결제는 거래취소 (login_expired)입니다.

{ "paymentId": "…", "status": "TRADING", "statusReason": null, "orderStatus": "WAITING" }

거래소가 주문을 거절하면 status 가 CANCELLED 이고 까닭이 statusReason 에 옵니다. orderStatus 는 거래소 주문 상태입니다. 방식이 api 가 아니면 409 wrong_mode, 결제가 거래중이 아니거나 취소 요청이 있으면 409 payment_not_open 입니다.

매칭 전 취소

POST /api/v1/payments/{paymentId}/cancel

본문은 없습니다. 매칭 전에만 됩니다. 매칭 뒤면 409 already_matched — 결제는 그대로 진행됩니다. 이미 거래완료면 409 payment_finished 입니다.

{ "paymentId": "…", "status": "CANCELLED", "statusReason": "cancelled_before_match" }

취소가 곧바로 끝나지 않으면 status 가 그대로이고, 끝나면 웹훅으로 알려 드립니다. 끝난 취소도 웹훅이 갑니다.

결제 상태 조회

GET /api/v1/payments/{paymentId}
필드무엇
paymentId결제 번호
modeapi · popup_wallet · popup_password
popupUrl팝업 둘의 주소. REQUESTED 면 다시 열 수 있습니다
status7절의 다섯 중 하나
statusReasonCANCELLED 일 때 까닭 (7절)
merchantUserId연동된 가맹점 회원 아이디
buyerWalletAddress구매자 지갑
merchantWalletAddress가맹점 지갑
amountUsde요청 수량 (예 "100.00")
tradeNo거래소 거래번호. 매칭 전에는 null. 한 번 받으면 바뀌지 않습니다
contract결제 컨트랙트 — { address, paymentId, status, merchantAmountUsde }. status 는 registered · paid · cancelled. 등록 전에는 null
arrivedUsde가맹점 지갑에 도착한 수량 (예 "99.73"). 확인 전에는 null
txHash블록체인 기록. 확인 전에는 null
createdAt · updatedAt만든 때 · 마지막으로 바뀐 때

지갑 잔액 조회

GET /api/v1/merchant/wallets/{walletAddress}/balance
{ "walletAddress": "sjo1…", "balanceUsde": "12.50", "height": … }

그때 체인에서 읽은 USDE 잔액과 블록 높이입니다. 한 번 더 확인하는 용도입니다 — 어느 결제의 입금인지는 가르지 못합니다. 결제의 입금은 웹훅 · 상태 조회의 arrivedUsde · txHash 로 보십시오. 체인을 읽지 못하면 503 chain_unavailable 입니다.

6. 웹훅 — 저희가 보내드리는 것

결제 상태가 바뀌면 저희가 등록해 주신 주소로 POST 합니다.

헤더무엇
X-EggPay-Signature보낸시각 + "." + 본문 을 웹훅 비밀키로 HMAC-SHA256 한 값 — 소문자 16진수
X-EggPay-Timestamp보낸 시각 — 1970년부터 흐른 초 (예: 1789545372)
signature = HMAC-SHA256(웹훅 비밀키, X-EggPay-Timestamp + "." + 받은 본문)

⚠ 받은 본문 그대로 서명해 주십시오. JSON 을 다시 만들면 띄어쓰기 하나로 서명이 달라집니다. 시각이 서명 안에 들어 있어, 한 번 가로챈 알림을 나중에 다시 보내도 가맹점이 걸러낼 수 있습니다.

서명을 반드시 검증하십시오. 검증하지 않으면 남이 가짜 「거래완료」를 보내 받지도 않은 코인에 충전을 해주게 됩니다.

본문의 필드는 결제 상태 조회와 같은 뜻입니다.

필드무엇
paymentId결제 번호
status바뀐 뒤 상태 — TRADING · COMPLETED · CANCELLED · DISPUTED
statusReason거래취소 까닭 (7절). incident 면 충전을 취소하라는 신호입니다
amountUsde요청 수량
tradeNo거래소 거래번호
contract{ address, paymentId, status, merchantAmountUsde }
arrivedUsde거래완료면 가맹점 지갑에 도착한 수량
txHash거래완료면 블록체인 기록
occurredAt상태가 바뀐 때

결제를 만들 때는 보내지 않습니다 — 요청 기록(REQUESTED), api 방식의 TRADING, 곧바로 거래취소(CANCELLED) 모두 결제 요청의 답으로 아십니다.

2xx 를 돌려주시면 됩니다. 3xx 는 따르지 않고 실패로 봅니다. 그 밖이면 6회까지 다시 보냅니다 — 30초 · 2분 · 8분 · 32분 · 2시간 · 8시간 뒤입니다 (기본값).

순서는 보장하지 않습니다. occurredAt 과 상태 조회로 최신을 가려 주십시오.

웹훅은 못 갈 수 있습니다. 서버가 잠깐 죽거나 네트워크가 끊기면 그 줄기가 끊깁니다. 상태 조회로 따라잡아 주십시오 — 그래서 둘 다 드리는 것입니다.

7. 결제 상태

값뜻가맹점이 하실 일
REQUESTED요청 기록 — 팝업 방식에서 구매 등록 전입니다. 진행 중이 아닙니다 기다립니다. 같은 popupUrl 로 다시 열 수 있습니다. 원하시면 매칭 전 취소로 닫으십시오
TRADING거래중기다립니다
COMPLETED거래완료 arrivedUsde 를 확인하고 충전해 주십시오. 원하시면 지갑 잔액 조회로 한 번 더 보십시오
CANCELLED거래취소충전건을 취소 처리해 주십시오. 까닭은 statusReason
DISPUTED분쟁결말이 날 때까지 기다립니다

결제는 거래완료나 거래취소 가운데 하나로 반드시 끝납니다.

거래취소 까닭 (statusReason)

값언제
popup_closed팝업 방식 — 구매 등록 뒤 매칭 전에 진행 화면을 닫음
cancelled_before_match매칭 전 취소 (가맹점 취소 · 진행 화면 [취소] · 거래소 앱)
request_closed팝업 방식의 요청 기록이 닫힘 (같은 구매자의 새 충전 · 연동 해제 · 구매 화면 [Cancel])
login_rejected · login_expiredapi 방식 — 지갑 앱 로그인 거절 · 5분 지남 (로그인 뒤 [구매] 없이 지남 포함)
not_member 등거래소 회원 자격이 없음 (거래소가 준 이름)
amount_out_of_range 등거래소가 주문을 거절함 (거래소가 준 이름)
wallet_undeliverable지갑 앱에 전할 수 없음
mismatch결제 번호 · 수량 · 주소가 맞지 않아 진행하지 않음
trade_cancelled거래가 취소됨 (서명 마감 등)
incident거래소 사고처리 — 충전 취소 신호
registration_mismatch결제 컨트랙트 등록이 맞지 않음
pay_failed가맹점 송금이 체인에서 확정되지 못함

8. 수수료

간편결제는 수수료를 받지 않습니다. 도착 수량에서 체인 전송 수수료와 가스만 빠집니다 — 100.00 USDE 미만은 전송 수수료 0.17 + 가스 0.02 USDE 고정, 100.00 USDE 부터는 전송 수수료 0.17% 와 가스 0.1% 를 각각 올림(0.01 USDE 단위)합니다.

결제 수량 amountUsde전송 수수료가스도착 arrivedUsde
"12.50"0.170.02"12.31"
"50.00"0.170.02"49.81"
"99.99"0.170.02"99.80"
"100.00"0.170.10"99.73"
"100.01"0.180.11"99.72"
"1000.00"1.701.00"997.30"
"1234.56"2.101.24"1231.22"

도착 수량은 체인이 정한 값(arrivedUsde)이 기준입니다. 식으로 다시 계산해 맞추지 않으셔도 됩니다.

100개 결제의 예

가맹점 지갑에 도착99.73 USDE
가맹점이 충전해 주실 것100개에 대한 머니

9. 관리자 웹

승인되면 가맹점 계정도 함께 드립니다. 관리자 웹에서 가맹점 계정으로 하실 수 있는 일은 이렇습니다.

무엇내용
거래내역누가(가맹점 회원 아이디 merchantUserId) · 어디서(출발 지갑) · 어디로(도착 지갑) · 몇 개 입금했는지 · 상태(요청 기록 · 거래중 · 거래완료 · 거래취소 · 분쟁) · 알림(전달됨 · 보내는 중 · 전달 실패)
결제 찾기손님 문의가 들어왔을 때 지갑 주소나 회원 아이디(둘 다 정확히 일치) 와 기간으로 찾으실 수 있습니다. 찾으시면 결제 건수 · 등록 합계 · 도착 합계 · 마지막 결제가 함께 보입니다
마이페이지내 가맹점 정보 · 지급 지갑 잔액 · API 키 목록 · 새 API 키 받기 · 비밀번호 바꾸기 · 정보 변경 신청
화면의 시각과 기간은 UTC 기준입니다 — 한국 시간과 9시간 차이가 납니다. 목록은 한 페이지에 50건씩 보이고, 지갑 주소는 줄여 보이다가 마우스를 올리면 전체가 뜹니다.
처음 드리는 비밀번호는 임시 비밀번호입니다. 첫 로그인 때 반드시 바꾸셔야 다른 화면으로 가실 수 있습니다.

9-1. 가맹점 정보를 바꾸려면

지갑 주소와 웹훅 주소는 스스로 바꾸실 수 없습니다. 신청해 주시면 저희가 확인하고 승인합니다.

바꾸는 것어떻게
가맹점 지갑 주소신청 → 저희 승인
웹훅 받을 주소신청 → 저희 승인
API 키 재발급고객센터로 요청해 주십시오. 저희가 새 키를 만들면 마이페이지에서 한 번 받아 가실 수 있습니다. 새 키를 서버에 넣으셨다고 알려 주시면 옛 키를 삭제합니다 — 그때까지 두 키가 함께 동작합니다. 웹훅 비밀키는 바뀌지 않습니다
가맹점 탈퇴고객센터로 요청해 주십시오. 저희가 확인하고 처리합니다. 처리되면 그때부터 로그인과 API 가 막히고 목록에서 사라집니다. 이미 들어온 결제 내역은 남습니다
왜 스스로 못 바꾸나요? 돈을 받는 주소는 한 번 바뀌면 그 뒤 결제가 전부 그리로 갑니다. 계정을 잃으셨을 때 알아채시기 전에 여러 건이 다른 곳으로 가지 않도록, 사람이 한 번 확인합니다.

10. 오류

{ "error": { "code": "invalid_address", "message": "…" } }

message 는 사람이 읽는 글입니다. 프로그램은 code 로 가르십시오.

codeHTTP창구뜻
invalid_request400모두형식 · 필수 칸 · 길이 · 모르는 필드 · 소수 셋째 자리
invalid_address400연동 시작 · 잔액 조회 · 결제 요청SJO 주소 체크섬이 틀림
merchant_wallet_mismatch400결제 요청가맹점 지갑이 등록한 지급 주소와 다름
unauthorized401환율 밖 모두키가 없거나 틀림
forbidden403환율 밖 모두승인된 가맹점이 아님
not_found404모두없는 결제 · 남의 결제, 해제할 연동이 없음
wallet_not_linked404결제 요청구매자 지갑이 이 가맹점에 연동되지 않음
method_not_allowed405모두부르는 방식이 틀림 (예: POST 창구를 GET 으로). Allow 헤더에 맞는 방식이 있습니다
conflict409결제 요청같은 merchantRequestId 에 다른 내용
wallet_link_conflict409연동 시작그 회원이 다른 주소로, 또는 그 주소가 다른 회원으로 연동됐거나 그런 요청이 살아 있음 (5분 안)
payment_in_flight409연동 해제 · 결제 요청그 지갑에 진행 중인 결제가 있음
buyer_busy409연동 해제 · 결제 요청같은 구매자의 다른 요청을 처리 중 — 같은 요청을 다시 보내 주십시오
wrong_mode409구매 등록방식이 api 가 아님
payment_not_open409구매 등록결제가 거래중이 아니거나 취소 요청이 있음
login_required409구매 등록지갑 앱 로그인 승인 전이거나 5분이 지남
already_matched409매칭 전 취소이미 매칭됨 — 결제는 그대로
payment_finished409매칭 전 취소이미 거래완료
wallet_undeliverable422연동 시작그 주소의 지갑 사용자가 없음
rate_limited429모두호출 한도 초과 — retryAfterSeconds 를 함께 드립니다
internal500모두저희 쪽 문제입니다. 다시 부르셔도 됩니다
exchange_unavailable503연동 해제 · 결제 요청 · 구매 등록 · 매칭 전 취소거래소가 답하지 않음 — 처리하지 않았습니다. 다시 부르셔도 됩니다
wallet_unavailable503매칭 전 취소지갑이 답하지 않음 — 다시 부르셔도 됩니다
chain_unavailable503잔액 조회 · 결제 요청체인을 읽지 못함 (결제 요청은 USDE 멈춤 상태) — 처리하지 않았습니다. 다시 부르셔도 됩니다
usde_paused503결제 요청USDE 가 멈춤
usde_not_configured503결제 요청USDE 설정 없음
rate_unavailable503환율환율을 지금 확인할 수 없음 — 이전 값을 드리지 않습니다

사용자가 거래소 회원이 아니거나 거래소 계정으로 결제할 수 없는 경우는 오류가 아닙니다 — 연동 확인의 exchange.ready · reason 과, 결제의 CANCELLED 와 statusReason(not_member 등)으로 알려 드립니다.