고객 결제와 판매자 지급을 혼동하지 않으면서 테스트, 대사, 자동화할 수 있는 결제 연동을 만들어요.
1. 각자의 역할 정하기
쇼핑몰은 상품 목록, 판매자 계정, 장바구니, 배송, 주문 이행을 맡아요. Wholly Crypto는 결제창, 결제 검증, 판매자 배분, 승인된 지급을 처리해요. 판매자 기록이 로그인 계정을 뜻하는 건 아니에요. 기존 쇼핑몰 플러그인은 여러 판매자의 장바구니를 자동으로 나누지 않아요.
- 하나의 통합 장바구니
- 하나의 고객 인보이스
- 검증된 판매자 몫
- 승인된 지급
고객은 먼저 프로젝트 지갑으로 결제해요. 운영자가 키를 관리하고 판매자에게 줄 자금을 보관해요. 고객이 각 판매자에게 직접 나눠 보내는 방식도, 판매자 입장에서 비수탁 서비스도 아니에요.
Marketplace는 Bitcoin 메인넷, 지원되는 EVM 코인과 표준 ERC-20 토큰을 지원해요. 판매자는 고객이 결제한 자산을 같은 네트워크에서 받아요. 법정화폐로 자동 환전되지 않아요. 결제 수신을 지원하는 30개 체인 모두가 Marketplace 지급을 지원하는 것은 아니에요.
2. 금액 계산하기
판매자 세 명이 각각 100달러어치 상품을 판매한다고 해볼게요. 이 예제에서는 스토어나 판매자별 재설정 없이 프로젝트 수수료를 4%로 설정해요.
| 배분 | 총액 | 운영자 수수료 | 판매자 수령액 |
|---|---|---|---|
| 판매자 한 명 | $100 | $4 | $96 |
| 세 명 합계 | $300 | $12 | $288 |
인보이스에 고정된 환율로 계산한 가치이며 실제로는 암호화폐로 지급해요. 미래의 달러 가치는 보장하지 않아요. 일반적인 1% 처리 수수료는 이 300달러 인보이스에 대해 선불 크레딧 3달러를 한 번 사용해요. 판매자마다 부과하는 게 아니에요. 이 수수료가 적용되면 총 수수료 수익 12달러에서 네트워크 비용을 빼기 전 9달러가 남아요.
Bitcoin 수수료나 EVM 가스에 쓸 여유 네이티브 코인을 따로 준비하세요. 수수료로 판매자의 보호된 원금을 사용하면 안 돼요. 일반 스윕은 Marketplace 수신 주소의 자금을 사용할 수 없어요.
3. 지갑과 판매자 준비하기
- Project → Marketplace → Settings에서 Marketplace를 켜고 스토어와 수수료를 정하세요. 설정 중에는 지급을 일시 중지하고 자동 규칙을 꺼 두세요.
- 프로젝트 지갑과 데이터베이스를 백업하세요. 스토어에서 필요한 BTC/EVM 결제 수단을 켜고 스캐너 준비 상태를 확인하세요. 처리 크레딧과 별도의 네이티브 수수료 자금도 채워 두세요.
- 각 판매자를 추가하세요. UUID를 쇼핑몰 판매자 ID와 연결해 저장하고 external_id에 해당 참조를 넣을 수 있어요. 지급 주소를 별도로 확인한 후 정확한 체인과 네트워크에 대해 승인하세요.
결제 수단마다 참여 판매자 모두에게 맞는 승인된 수신 주소가 있어야 해요. 나중에 판매자 주소를 바꿔도 기존 지급 의무의 목적지가 몰래 바뀌지는 않아요.
지급 검증에는 0보다 큰 확인 수와 호환되는 독립 제공업체 두 곳이 필요해요. 결제창이 0회 확인이나 스캐너 한 곳을 허용해도 마찬가지예요.
4. 통합 장바구니 연결하기
Settings → API access에서 프로젝트 범위의 Marketplace 자격 증명을 만드세요. 결제 백엔드에 marketplace.read와 invoices.write를 주고, 필요하면 해당 스토어로 제한하세요. 주소 승인과 지급 승인 권한은 이 키에 넣지 마세요.
가격, 할인, 세금, 배송비를 서버에서 계산해 판매자 몫에 배분하세요. 서로 다른 판매자 1~100명과 양수 금액을 십진수 문자열로 보내세요. 총액의 합은 인보이스 금액과 정확히 같아야 해요. 브라우저가 보낸 배분값을 신뢰하거나 금액 계산에 부동소수점을 쓰지 마세요.
아래의 API 호스트명과 UUID 자리표시자를 바꾸세요. 서버 환경에서 WHOLLY_TOKEN을 읽어 오세요. 요청은 설정된 4% 수수료를 그대로 사용하므로 수수료 변경 권한이 필요 없어요.
cURL, JavaScript, PHP, Python 요청 예제 열기
cURL
: "${WHOLLY_TOKEN:?Set WHOLLY_TOKEN to your server-side API token}"
# Keep this key and the exact body for retries; use a new key for each new operation.
curl --fail-with-body --max-time 30 \
--request POST \
--url "https://api.example.com/v1/marketplace/invoices" \
--header "Authorization: Bearer $WHOLLY_TOKEN" \
--header 'Idempotency-Key: cart-1042-marketplace-v1' \
--header 'Content-Type: application/json' \
--data-raw '{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}'JavaScript
// Node.js 18+ · run on your server, never in browser code.
const token = process.env.WHOLLY_TOKEN;
if (!token) throw new Error("Set WHOLLY_TOKEN");
// Keep this key and the exact body for retries; use a new key for each new operation.
const body = `{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}`;
const response = await fetch("https://api.example.com/v1/marketplace/invoices", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Idempotency-Key": "cart-1042-marketplace-v1",
"Content-Type": "application/json"
},
body,
redirect: "error",
signal: AbortSignal.timeout(30000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
console.log(await response.json());PHP
<?php
// PHP 8+ with the cURL extension; run on your server.
$token = getenv('WHOLLY_TOKEN');
if (!$token) { throw new RuntimeException('Set WHOLLY_TOKEN'); }
// Keep this key and the exact body for retries; use a new key for each new operation.
$body = <<<'JSON'
{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}
JSON;
$ch = curl_init("https://api.example.com/v1/marketplace/invoices");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, "Idempotency-Key: cart-1042-marketplace-v1", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) { throw new RuntimeException(curl_error($ch)); }
curl_close($ch);
if ($status < 200 || $status >= 300) { throw new RuntimeException("HTTP $status: $response"); }
print_r(json_decode($response, true, 512, JSON_THROW_ON_ERROR));Python
# Python 3 · standard library; run on your server.
import json
import os
from urllib.request import Request, build_opener, HTTPRedirectHandler
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
# Keep this key and the exact body for retries; use a new key for each new operation.
headers = {
"Idempotency-Key": "cart-1042-marketplace-v1",
"Content-Type": "application/json"
}
headers["Authorization"] = "Bearer " + os.environ["WHOLLY_TOKEN"]
body = """{
"project_id": "YOUR_PROJECT_ID",
"store_id": "YOUR_STORE_ID",
"amount": "300.00",
"currency": "USD",
"order_id": "cart-1042",
"description": "One order from three vendors",
"ipn_url": "https://shop.example.com/payments/wholly-ipn",
"metadata": {
"cart_id": "cart-1042"
},
"allocations": [
{
"vendor_id": "VENDOR_A_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_B_UUID",
"gross_amount": "100.00"
},
{
"vendor_id": "VENDOR_C_UUID",
"gross_amount": "100.00"
}
]
}""".encode("utf-8")
request = Request("https://api.example.com/v1/marketplace/invoices",
method="POST", headers=headers, data=body)
# Non-2xx responses raise HTTPError. Do not retry writes with a new key.
with build_opener(NoRedirect()).open(request, timeout=30) as response:
print(json.load(response))보내기 전에 요청 본문과 Idempotency-Key를 저장하세요. 타임아웃 후에는 같은 본문과 같은 키로 재시도하세요. 주문에 data.invoice_id를 저장하고 응답 최상위의 links.checkout으로 이동시키세요. 고객 브라우저에 API 키를 넣으면 안 돼요.
SDK를 쓰고 싶나요? Marketplace 예제로 시작하세요:
5. 고객 결제와 판매자 지급 나누어 추적하기
결제창에서 돌아왔다고 결제가 증명되는 것은 아니에요. 원본 본문으로 콜백 서명을 검증하고 타임스탬프와 예상 프로젝트를 확인하세요. 수신 확인 응답 전에 event_id를 고유하게 저장하세요. 주문 단위 중복 방지도 추가해 재시도로 주문을 두 번 처리하지 않도록 하세요.
| 이벤트 | 알려 주는 내용 |
|---|---|
invoice.settled | 고객 인보이스가 결제 완료됐어요. 주문 이행 전에 검토 표시와 Marketplace 보류를 확인하세요. |
marketplace.allocations.available | 검증된 몫을 지급할 수 있어요. 아직 판매자에게 돈이 지급됐다는 뜻은 아니에요. |
marketplace.payout.confirmed | 지급에 필요한 확인 검사가 완료됐어요. |
인보이스 IPN은 스토어의 IPN 비밀키를 사용해요. Marketplace 생명주기 웹훅은 엔드포인트별 자체 서명 비밀키를 사용해요. 두 수신 처리를 분리하세요. 누락되거나 순서가 바뀐 이벤트를 대사할 때는 API로 현재 인보이스나 지급 상태를 확인하세요.
6. 먼저 검토하고 나중에 자동화하기
검증된 몫을 사용할 수 있게 되면 지급 일시 중지를 풀되 자동 규칙은 꺼 두세요. Marketplace → Payouts에서 배분을 준비하고 수신자, 네이티브 수수료와 가스 한도를 검토한 뒤 정확한 계획을 한 번 승인하세요. Broadcast가 아니라 Paid까지 확인하세요.
Bitcoin은 선택한 인보이스의 미지급 몫을 모두 한 배치로 묶어요. EVM 토큰은 전송 전에 가스 자금 보충이 필요할 수 있어요. 여러 트랜잭션으로 진행되며 전부 성공하거나 전부 취소되는 원자적 분할이 아니에요.
소액 테스트가 성공하면 자산별 자동 규칙을 설정하세요. 최소 지급액, 건별·일일 원금 한도, 네이티브 수수료·가스 예산, 주기, 확인 수를 정해요. 켜면 추가 클릭 없이 전송을 허용해요. 크레딧 부족이나 서버 전송 제한이 있으면 지출은 계속 일시 중지될 수 있어요.
7. 예외 상황 처리하기
부족 입금, 늦은 입금, 혼합 결제, 체인 재구성
결제창이 완료돼도 판매자 몫이 모두 충당된 것은 아니에요. 허용 오차가 부족한 자금을 만들어 주지는 않아요. 보류된 배분을 확인하고 추가 입금을 기다리거나 환불하거나, 자금이 전부 확보된 더 작은 배분을 명시적으로 승인하세요. 초과 입금이 자동으로 마켓플레이스의 추가 수익이 되지는 않아요.
지급이 멈추거나 요청이 타임아웃됨
저장된 트랜잭션 해시, 출발지 잔액, 가스, 검토 사유를 확인하세요. 기존 지급을 재개하거나 대사하세요. 응답을 잃었다고 두 번째 전송을 만들지 마세요. 백업 복원 후에는 온체인 결과와 원장이 일치할 때까지 지급을 멈춰 두세요.
고객이 환불을 요청함
고객이 관리하는 환불 주소를 확인하세요. 지원되는 환불은 같은 자산의 전액 환불이에요. 모든 판매자에게 이미 지급했다면 기본 지갑에 별도 자금을 넣어야 해요. Wholly Crypto가 판매자에게서 다시 인출할 수는 없어요. 일부만 지급된 배치는 수동 대사가 필요하며 부분 환불 버튼이 있다고 가정하면 안 돼요.
8. 오픈 전 확인하기
- 소액 BTC 및/또는 EVM 결제를 판매자 지급 확인까지 테스트하세요. 체인, 토큰 계약, 목적지, 수수료 수익과 별도 비용을 확인하세요.
- 중복 콜백, API 응답 타임아웃, 부족 입금, 보류 지급을 테스트하세요. 중복 주문 처리나 중복 전송이 없는지 확인하세요.
- 비공개 백업을 서버 밖에 보관하고 지급 실패를 모니터링하며, 판매자에게 지급할 금액과 온체인 자금을 정기적으로 대사하세요.
소수 판매자와 검토 후 지급 방식으로 시작하세요. 테스트한 자산, 예산, 목적지만 자동화하세요.