암호화폐 결제 받기
Paysell은 TON 네트워크에서 TON과 USDT로 정산합니다. 인보이스를 생성하면 링크를 드리고, 온체인에서 입금이 확인되어 잔액에 반영되면 서명된 콜백을 받게 됩니다.
개요#
Paysell이 하는 일과 하지 않는 일.
Paysell은 결제 처리 서비스이지 지갑이 아닙니다. 개인 키를 다루거나, 블록체인을 감시하거나, 트랜잭션이 언제 최종 확정되는지 판단할 필요가 전혀 없습니다 — 그 부분은 저희가 맡습니다.
모든 인보이스는 고유한 수신 주소를 가집니다. 구매자가 결제하면 네트워크의 전송 확인을 기다렸다가 수수료를 차감하고 나머지를 잔액에 반영합니다. 원하는 어떤 주소로든 출금할 수 있습니다.
결제가 진행되는 방식#
여섯 단계, 대부분은 저희가 처리합니다.
여섯 단계, 대부분은 저희가 처리합니다.
- 1
고객이 결제 버튼을 클릭
귀하의 서버가 금액과 자체 주문 참조 번호와 함께 저희 API를 호출합니다.
- 2
저희가 주소를 발급
미리 생성된 풀에서 새 수신 주소를 가져와 이 인보이스에 연결합니다. 하나의 주소는 정확히 하나의 미결 인보이스에 속하며, 이 방식으로 결제가 해당 인보이스와 매칭됩니다.
- 3
고객이 코인을 전송
QR 코드를 스캔하거나 주소를 복사합니다. 반환된
payment_url로 안내하면 페이지가 알아서 처리됩니다 — 금액, 주소, QR, 카운트다운, 실시간 상태까지. - 4
전송을 감지
블록체인 데이터의 두 개의 독립된 소스를 조회하여 그 결과를 비교합니다. 두 결과가 일치하지 않으면, 더 편리한 쪽을 고르는 대신 처리를 멈춥니다.
- 5
최종 확정을 대기
마스터체인 편입에 추가로 세 블록. 약 15초 — 정산된 것처럼 보였다가 나중에 사라지는 결제는 귀하의 손실이 되므로, 그런 위험은 감수하지 않습니다.
- 6
잔액 반영 및 알림
수수료가 차감되고, 나머지가 귀하의 잔액에 반영되며,
order_id를 담은 서명된 웹훅이 귀하의 서버로 전송됩니다.
결제부터 콜백까지 약 1분: 네트워크 확인에 약 15초, 나머지는 저희가 감시 중인 주소들을 훑는 처리 시간입니다.
돈의 흐름#
수수료와 그 산정 기준.
수수료는 0.2%이며, 귀하의 상점이 등록되는 시점에 고정됩니다. 이후 표준 요율이 변경되더라도 귀하의 요율은 바뀌지 않습니다 — 설정에 대한 참조가 아니라 각 인보이스에 숫자로 기록되기 때문입니다.
수수료는 인보이스에서 요청한 금액이 아니라 실제로 도착한 금액에서 부과됩니다. 5 USDT를 청구했는데 20 USDT가 들어오면 수수료는 20을 기준으로 계산됩니다. 부족하게 입금되면 실제로 들어온 금액을 기준으로 계산됩니다.
Invoice: 5.000000 USDT
Received: 20.000000 USDT (the buyer sent more)
Fee 0.2%: 0.040000 USDT (on 20, not on 5)
Credited: 19.960000 USDT초과 입금은 전액 잔액에 반영됩니다 — 저희가 차액을 가져가지 않습니다. 부족 입금은 구매자가 같은 주소로 추가 입금할 수 있도록 인보이스를 열린 상태로 둡니다.
빠른 시작#
첫 인보이스까지 5분.
다섯 단계입니다. 두 개는 계정 영역에서의 클릭이고, 하나는 귀하의 서버에서 보내는 요청 한 번이며, 나머지 두 개는 저절로 일어납니다.
- 1
상점 생성
계정 영역에서 진행합니다. 심사 대기 없이 즉시 결제를 받기 시작합니다. 인증은 백그라운드에서 조용히 진행되며 입금이 아닌 출금만 제한합니다.
- 2
API 키 발급
상점 → API 키 → 새 키. 키와 웹훅 시크릿은 한 번만 표시되고 다시는 표시되지 않습니다. 데이터베이스 비밀번호처럼 보관하고, 절대 브라우저로 전송하지 마세요.
- 3
인보이스 생성
귀하의 서버에서 요청 한 번, 링크 하나가 돌아옵니다. 아래 네 개의 예제는 모두 정확히 같은 것을 보냅니다.
- 4
구매자를
payment_url로 보내기이것이 결제 과정의 전부입니다 — 금액, 주소, QR 코드, 카운트다운, 실시간 상태 — 따로 구축할 것은 없습니다. 구매자가 실제로 보는 화면은 결제 페이지를 참조하세요.
- 5
웹훅 기다리기
돈이 체인에서 확인되어 잔액에 반영되면, 저희가 서명된
payment.credited이벤트를 귀하의 서버로 POST합니다. 서명을 검증한 다음 주문을 결제 완료로 표시하세요 — 단,data.status가paid또는overpaid일 때만입니다. 웹훅 참조.
The same request, four ways
curl -X POST https://paysell.me/api/merchant/v1/invoices \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset": "USDT_TON",
"amount": "5",
"order_id": "order-1042",
"idempotency_key": "order-1042"
}'응답에 있는 payment_url로 구매자를 리디렉션하세요. 이것으로 끝입니다 — 나머지는 웹훅으로 도착합니다.
What to do next
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
인증#
API 키와 그 사용 방법.
모든 요청은 Authorization 헤더에 키를 담아 전송됩니다.
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA여기서 발급되는 모든 키는 sk_live_로 시작합니다. sk_test_ 접두사는 테스트 네트워크를 향한 배포에만 존재하며, 저희는 그런 배포를 제공하지 않습니다 — 테스트 참조. 저희는 키 자체가 아니라 단방향 해시를 저장하므로, 저희를 포함해 그 누구도 키를 다시 보여드릴 수 없습니다. 분실했다면 새 키를 발급하고 이전 키를 폐기하세요.
상점은 키로부터 결정되므로 어떤 요청도 상점 ID를 필요로 하지 않습니다. 하나의 키는 오직 자신의 상점에서만 동작할 수 있습니다.
주소에 버전이 들어갑니다: /api/merchant/v1/…. 한 버전 안에서는 필드를 추가만 합니다 — 이름을 바꾸거나 조용히 의미를 바꾸지 않습니다. 여러분의 코드를 깨뜨릴 변경은 새 접두사 /v2를 받고, /v1은 공지한 기간 동안 계속 동작합니다.
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
인보이스 생성#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoices요청 본문
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| asset | string | 예 | TON 또는 USDT_TON 중 하나. |
| amount | string | 예 | 코인의 일반 단위, 문자열로: "5"는 5 USDT입니다. 소수점 자리는 코인이 가진 자릿수까지만. 금액 참조. |
| order_id | string | 아니오 | 귀하의 자체 참조값, 최대 200자. 모든 웹훅으로 반환됩니다 — 이를 통해 결제를 주문과 매칭합니다. |
| description | string | 아니오 | 최대 1000자. 결제 페이지에서 구매자에게 표시됩니다. |
| ttl_minutes | number | 아니오 | 인보이스가 결제 가능한 상태로 유지되는 시간(분). 1–1440. 생략하면 기본값이 적용되며, 현재는 2시간입니다. |
| idempotency_key | string | 아니오 | 최대 200자. 재시도 시 같은 값을 보내면 두 번째 인보이스가 아닌 동일한 인보이스를 돌려받습니다. 이것은 본문 필드이며 Idempotency-Key 헤더가 아닙니다 — 여기서 그 헤더는 읽지 않습니다. |
응답 · 201
{
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
"address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
"asset": "USDT_TON",
"amount": "5",
"amount_minor": "5000000",
"status": "pending",
"paid": "0",
"paid_minor": "0",
"order_id": "order-1042",
"description": "Pro subscription",
"expires_at": "2026-09-06T17:20:55Z",
"created_at": "2026-09-06T15:20:55Z"
}주문에 매핑하기
| 필드 | 처리 방법 |
|---|---|
| invoice_id | 귀하의 주문에 연결해 저장하세요. 이후 어디서든 결제를 식별하는 값입니다. |
| payment_url | 구매자를 이곳으로 리디렉션하세요. 그 외에 구축할 것은 없습니다. |
| address | 직접 결제 화면을 렌더링하는 경우에만 사용합니다. 반환된 그대로 정확히 표시하세요 — 아래 경고 참조. |
| amount | 보낸 그대로의 일반 단위 금액. 화면에는 이것을 표시하세요. |
| amount_minor | 같은 금액의 최소 단위 정수. 계산에는 이것을 쓰세요. |
| expires_at | 카운트다운을 표시하세요. 이 시점이 지나면 해당 인보이스에 대한 주소 감시가 중단됩니다. |
| status | 여기서는 항상 pending입니다. 실제 변경 사항은 웹훅으로 도착합니다. |
UQ…, 테스트넷에서는 0Q…). 이를 변환하거나, 보기 좋게 꾸미거나, 같은 주소의 다른 인코딩으로 바꾸면 아직 배포되지 않은 지갑으로 보낸 코인이 발신자에게 되튕겨 돌아갑니다.인보이스 조회#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}위와 동일한 구조이며, status, paid, paid_minor가 현재 상태를 반영합니다. paid는 일반 단위로 지금까지 도착한 금액이고, paid_minor는 같은 값을 최소 단위의 정수로 나타낸 것입니다. 웹훅을 놓쳤을 때의 대체 수단이나 감사 페이지에서 유용합니다.
폴링은 최대 몇 초에 한 번으로 제한하고, 웹훅을 주된 채널로 취급하세요. 다른 상점에 속한 인보이스는 403이 아닌 404를 반환하므로 ID의 존재 여부를 탐색할 수 없습니다.
인보이스 취소#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancel아직 열려 있는 인보이스 — pending 또는 underpaid — 를 종료하고 해당 주소를 반환합니다. 고객이 결제를 포기했을 때 사용하세요 — 주소는 유한한 자원이며, 반환하는 것이 풀을 건강하게 유지합니다.
더 이상 열려 있지 않은 인보이스는 409를 반환합니다. underpaid 인보이스를 취소해도 누구에게도 코인이 돌아가지 않습니다. 이미 반영된 돈은 귀하의 잔액에 그대로 남고, 닫히는 것은 추가 입금을 받는 일뿐입니다.
웹훅#
무엇이 도착하며, 어떻게 검증하는가.
키를 만들 때 웹훅 주소를 지정하세요. 결제가 잔액에 반영될 때, 그리고 추가 확인으로 넘어간 입금이 거부될 때 그 주소로 POST를 보냅니다. 모든 전달에는 서명이 붙으며, 2xx로 응답할 때까지 약 하루 반 동안 재시도합니다. 상품은 status: paid 또는 overpaid를 보고 내보내세요. 호출이 도착했다는 사실만으로는 안 됩니다.
이벤트
| 이벤트 | 언제 | 본문에 담기는 것 |
|---|---|---|
| payment.credited | 온체인에서 전송이 확인되고, 저희 수수료가 제해지고, 나머지가 귀하의 잔액에 들어왔을 때. | 아래에 나열된 필드들. |
| payment.rejected | 추가 확인을 위해 보류되었던 입금(상태 레퍼런스 참조)이 거부되었을 때. 그 돈은 잔액에 들어오지 않습니다. | invoice_id, order_id, asset, amount, tx_hash, reason. 상품을 내보내지 마세요. 청구서가 이전 전송으로 이미 paid였다면, 이 이벤트는 그 결제가 아니라 추가 입금에 관한 것입니다. |
도착하는 내용
{
"event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
"type": "payment.credited",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"credited": "4905000",
"fee": "95000",
"status": "paid",
"paid_minor": "5000000",
"tx_hash": "97a1f0…"
}
}{
"event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "payment.rejected",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"tx_hash": "97a1f0…",
"reason": "could not be matched to any order"
}
}필드 매핑
| 필드 | 의미 |
|---|---|
| event_id | 이벤트마다 고유하며, X-Paysell-Event-Id 헤더에도 들어 있습니다. 저장해 두고 반복은 무시하세요 — 아래 참조. |
| data.order_id | 귀하의 참조값. 이를 기준으로 주문을 조회하세요. |
| data.amount | 이번 전송에서 구매자가 보낸 금액, 최소 단위 — 일반 단위를 받는 API와는 반대입니다. |
| data.fee | 저희가 뗀 금액, 최소 단위. |
| data.credited | 잔액에 들어온 금액: amount − fee, 최소 단위. |
| data.paid_minor | 이 인보이스로 지금까지 받은 총액, 최소 단위. underpaid에서 중요한 필드입니다. 상태는 적게 도착했다고만 말하고, 이 값이 얼마나 적은지를 말해 줍니다. |
| data.asset | 실제로 도착한 코인. 인보이스가 요청한 코인이라는 보장은 없습니다. |
| data.asset_mismatch | 도착한 코인이 인보이스의 코인과 다를 때만 나타나며, 값은 true입니다. 돈은 잔액에 반영되지만 인보이스는 미결제로 남고, status가 paid가 되는 일은 없습니다. |
| data.invoice_asset | asset_mismatch와 함께 옵니다: 인보이스가 실제로 요구하는 코인. |
| data.status | 현재 인보이스의 상태: pending, underpaid, paid, overpaid 또는 expired. 기대했던 값과 비교하세요. |
| data.tx_hash | 온체인 트랜잭션. 기록 및 지원용. |
모든 전달에 포함되는 헤더
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| 헤더 | 의미 |
|---|---|
| X-Paysell-Event | 이벤트 종류: payment.credited 또는 payment.rejected. |
| X-Paysell-Event-Id | 이벤트마다 고유합니다. 중복 제거의 기준으로 삼을 값입니다. |
| X-Paysell-Timestamp | 서명한 시각, 유닉스 초 단위. 서명 대상 문자열의 일부입니다. |
| X-Paysell-Signature | sha256= 뒤에 16진수 HMAC이 붙습니다. 아래를 참조하세요. |
서명 검증
모든 요청은 키를 만들 때 한 번만 표시된 웹훅 시크릿으로 서명됩니다. 서명은 HMAC-SHA256(secret, "{timestamp}.{raw_body}")입니다 — X-Paysell-Timestamp의 타임스탬프, 마침표 하나, 그 뒤에 본문 바이트. 처리하기 전에 검증하세요. 이것이 없으면 귀하의 URL을 알아낸 누구든 결제된 주문을 만들어 건넬 수 있습니다.
Python:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".").update(body) // body is the raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}수신한 그대로의 원시 본문 바이트에 서명하세요. JSON을 파싱한 뒤 다시 직렬화하면 키 순서와 공백 때문에 바이트가 바뀌고, 서명이 일치하지 않게 됩니다. 비교는 상수 시간으로 하세요 (hmac.compare_digest, crypto.timingSafeEqual). 평범한 ==는 첫 바이트가 틀렸을 때 더 빨리 반환하는데, 그 차이만으로도 서명을 한 바이트씩 알아낼 수 있습니다.
타임스탬프 허용 범위
타임스탬프가 자기 시계와 어느 방향으로든 5분 넘게 벌어진 요청은 거부하세요. 타임스탬프가 서명 대상 문자열 안에 들어 있는 것은 서명을 깨뜨리지 않고서는 고칠 수 없게 하기 위함이고, 허용 범위는 그것을 실제 보호 장치로 만들어 줍니다. 이것이 없으면 한 번 가로챈 요청이 영원히 유효한 채로 남아 언제든 재전송될 수 있습니다 — 서명 자체에는 만료가 없습니다. 서버 시계는 NTP에 맞춰 두세요. 그렇지 않으면 이 검사가 정상적인 전달을 거부하기 시작합니다.
중복
같은 이벤트가 두 번 이상 도착할 수 있습니다. 이는 버그가 아닙니다. 귀하가 2xx로 응답할 때까지 재시도하며, 성공했지만 응답이 저희에게 도달하지 못한 전달은 다시 전송됩니다. X-Paysell-Event-Id를 기록해 두고 (본문에도 event_id로 들어옵니다) 두 번째 도착 시에는 아무 일도 하지 않도록 하세요.
재시도
첫 시도는 결제가 잔액에 반영되는 즉시 나갑니다. 실패하면 — 타임아웃, 연결 거부, TLS 오류, 리디렉션, 또는 2xx가 아닌 모든 상태 — 정해진 일정에 따라 재시도합니다:
1분 → 5분 → 15분 → 1시간 → 6시간 → 24시간총 일곱 번의 시도가 약 31시간에 걸쳐 이루어집니다. 초반 시도가 촘촘한 것은 흔한 원인이 재시작 중이었다가 이미 복구된 수신 서버이기 때문이고, 후반 시도가 성긴 것은 하루 종일 죽어 있는 서버를 두드려 봐야 누구에게도 도움이 되지 않기 때문입니다.
마지막 시도 이후 해당 전달은 dropped로 표시되고 저희 쪽에서 알아서 중단합니다. 사라지는 것은 아닙니다. 계정 영역의 결제 항목에 상태, 시도 횟수, 오류 분류가 표시되며, 다시 보내기 버튼을 누르면 일곱 번의 시도가 처음부터 새로 시작됩니다. 다른 수단은 GET /api/merchant/v1/invoices/{invoice_id}입니다 — 인보이스는 언제나 자신의 상태를 알고 있습니다.
웹훅 URL이 갖춰야 할 조건
URL은 저장할 때 한 번, 그리고 매 전달 직전에 다시 검사됩니다. 검사를 통과하지 못한 URL은 저장 시점에 422와 code: "webhook_url_rejected"로 응답하며, 나중에 실패하기 시작하면 해당 전달을 failed로 표시하고 재시도하지 않습니다. 규칙은 다음과 같습니다:
- `https://`만, 그리고 포트 443. 웹훅은 결제 정보를 담고 있으며, 평문 http에서는 경로상의 누구나 그것을 읽을 수 있습니다.
- IP 주소가 아니라 도메인 이름. 어차피 인증서가 필요하고, 맨 IP에는 인증서가 발급되지 않습니다.
- `localhost` 금지,
.local,.internal,.corp,.lan,.test이름도 금지 — 저희 서버는 귀하의 네트워크에 닿을 수 없고, 저희 내부에서 해석되는 이름이야말로 절대 호출해서는 안 되는 대상입니다. - URL에 자격 증명 금지 (
https://user:pass@…). 토큰이 필요하다면 경로나 쿼리 파라미터에 넣으세요. - 이름이 해석되는 모든 주소가 공인 주소여야 합니다 — A와 AAAA 둘 다. 사설, 루프백, 링크로컬, CGNAT 대역은 거부되며, 이 검사는 매 전달 직전에 반복되므로 나중에 레코드를
127.0.0.1로 돌려놓는 것도 통하지 않습니다. - 리디렉션은 경유가 아니라 실패입니다. 저희는 따라가지 않습니다. 귀하가 알려준 주소는 검사를 거쳤지만,
Location헤더에 들어 있는 주소는 그렇지 않기 때문입니다.
빠르게 응답하기
10초 이내라면 어떤 2xx든 괜찮습니다 — 연결까지 포함한 전체 타임아웃이 그만큼입니다. 먼저 응답하고 느린 작업은 그다음에 하세요. 자기 데이터베이스를 기다렸다가 응답하는 엔드포인트는 결국 타임아웃으로 기록되어 재시도를 받게 되고, 귀하는 같은 이벤트를 두 번 처리하게 됩니다. 그 밖의 모든 것 — 4xx, 5xx, 리디렉션, 응답 없음 — 은 실패한 시도로 계산되어 위의 일정으로 되돌아갑니다.
전달에 대한 솔직한 이야기
보장하는 것은 전달 메커니즘입니다. 약 31시간에 걸친 일곱 번의 시도, 계정 영역에서의 수동 재전송, 그리고 언제나 진짜 상태를 알고 있는 인보이스 엔드포인트. 웹훅이 끝내 도착하지 않아도 아무 손해가 없도록 흐름을 설계하세요 — 감사 페이지에서 인보이스를 조회하거나, 한 시간에 한 번 열린 인보이스를 대조하세요. 웹훅은 빠른 경로일 뿐, 유일한 경로가 아닙니다.
A complete receiver#
Signature, deduplication and a fast answer, end to end.
The snippets above verify one signature. This is the whole endpoint: raw body, signature check, deduplication by event_id, a fast 2xx, and the one condition that is allowed to mark an order paid.
Node.js with Express. express.raw is the part people get wrong: express.json() hands you a parsed object, and bytes you re-serialise from it are not the bytes we signed.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
const sentAt = Number(timestamp)
if (!Number.isFinite(sentAt)) return false
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // ±5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + ".").update(body) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
상태 레퍼런스#
모든 인보이스 및 결제 상태에 대한 설명.
인보이스
| 상태 | 의미 | 할 일 |
|---|---|---|
| pending | 결제 대기 중. | 주문을 열린 상태로 유지하세요. |
| paid | 전액 결제됨. | 상품을 제공하세요. |
| overpaid | 요청한 것보다 많이 도착함. 초과분은 전액 귀하에게 반영됩니다. | 상품을 제공하세요. 원하면 차액을 환불하세요. |
| underpaid | 요청한 것보다 적게 도착함. 인보이스는 열린 채로 주소를 그대로 유지합니다. 구매자는 같은 곳으로 추가 입금할 수 있고, paid_minor가 지금까지 들어온 금액을 알려 줍니다. 남은 유효 기간에 더해 expires_at 이후 24시간의 유예 기간 동안 계속 결제할 수 있습니다. | 추가 입금을 기다리거나 고객과 합의하세요. 상품은 내보내지 마세요 — 인보이스는 결제되지 않았습니다. |
| expired | 유예 기간까지 포함해 기한이 닫혔습니다. 여전히 돈이 들어 있을 수 있습니다: 도착한 금액은 그대로 귀하의 잔액에 남아 있으며, paid_minor가 그 액수를 알려 줍니다. | 새 인보이스를 제시하세요. 기존 주소로의 결제는 받지 마세요. 인보이스가 만료되면 주소는 풀로 돌아가며, 아주 늦게 도착한 송금은 자동 반영이 아니라 지원팀이 처리할 사안이 됩니다. 아무것도 받지 못했다고 고객에게 말하기 전에 paid_minor를 확인하세요. |
| cancelled | 귀하에 의해 취소됨. 주소는 풀로 반환됩니다. | 할 일 없음. |
결제
계정 영역에서 확인 가능하며, 결제 중인 고객을 지원할 때 유용합니다.
| 상태 | 의미 |
|---|---|
| detected | 체인에서 감지됨, 확인 대기 중. |
| confirmed | 네트워크가 확인함. 다음은 잔액 반영. |
| credited | 귀하의 잔액에 반영됨. 이 시점에 웹훅이 발생합니다. |
| review | 추가 확인을 위해 보류됨 — 예를 들어, 미결 인보이스가 없는 주소로 코인이 도착한 경우. |
| rejected | 반영되지 않음. 사유가 기록됩니다. |
결제가 `review`로 넘어갈 때
일부 입금은 곧바로 반영되는 대신 추가 확인을 위해 보류됩니다. 유난히 큰 금액, 열린 인보이스가 없는 주소로 도착한 코인, 또는 저희가 조회하는 두 블록체인 소스가 서로 다른 이야기를 하는 경우입니다. 잃어버리는 것은 없습니다 — 돈은 판단이 내려질 때까지 기다리고, 판단이 나오는 즉시 웹훅이 발생하며, 그것은 몇 분 뒤일 수도 몇 시간 뒤일 수도 있습니다. review로 표시된 결제에 콜백이 오지 않는 것은 실패가 아니라 정상으로 보세요. 주문에 영향이 있다면 tx_hash를 알려 주며 지원팀에 문의하세요.
금액#
보낼 때는 일반 단위, 돌아올 때는 최소 단위.
금액은 코인의 일반 단위로, 문자열로 보냅니다 — "1.5"는 1.5입니다. JSON 숫자도 아니고 최소 단위도 아닙니다.
| 자산 | 소수 자릿수 | 보내는 값 | 응답의 amount_minor |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
숫자가 아니라 문자열인 이유는 JSON 숫자가 IEEE-754 double이라, 나노톤 단위의 큰 금액은 그 안에 정확히 담기지 않게 되기 때문입니다. 코인이 가진 자릿수보다 많은 소수점은 422이며, 당신의 돈을 조용히 반올림하는 일은 없습니다. 웹훅은 반대입니다: 그쪽의 amount, fee, credited는 최소 단위의 정수입니다. 그 편을 읽는 것은 사람이 아니라 코드이기 때문입니다.
// Send amounts in the coin's normal units, as a string:
const amount = "1.5" // one and a half TON or USDT
// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor) // e.g. 1500000n제한#
최소값, 최대값, 그리고 속도 제한.
| 제한 | 값 | 위반 시 |
|---|---|---|
| 최소 인보이스 금액 | 0.1 TON · 3 USDT | 422 |
| 최대 인보이스 금액 | 7000 TON · 10000 USDT | 422 |
| 상점당 시간당 인보이스 수 | 60 | 429 |
| 동시에 열린 인보이스 수 | 20, 결제된 인보이스마다 증가, 최대 200 | 429 |
| 인보이스 유효 기간 | 1분 – 24시간 (기본값 2시간) | 422 |
| 키당 API 요청 수 | 분당 120 | 429 + Retry-After |
최소값은 관료적인 절차가 아닙니다. 저희 수수료는 비율제이지만, 결제를 받아 처리하는 데에는 고정 비용이 듭니다. 수신 주소에서 USDT를 옮기려면 먼저 저희 돈으로 가스를 채워 넣어야 합니다. 몇 달러 미만에서는 수수료가 처리 비용을 감당하지 못하며, 그런 결제를 받아들이는 것은 옮기는 것 자체가 수지에 맞지 않는 돈을 귀하에게 반영해 주는 셈이 됩니다.
최대값은 큰 상점을 겨냥한 것이 아니라 단위 착오를 잡는 덫입니다. "5"를 뜻하면서 "5000000"을 보내면 그대로 오백만 달러짜리 인보이스가 만들어집니다. 구매자는 말도 안 되는 금액을 보고 떠납니다. 진짜 주문은 이 천장에 결코 닿지 않고, 착오는 언제나 닿습니다. 두 상한 모두 설정값(invoice_max_ton, invoice_max_usdt)이며 귀하의 상점에 맞춰 올릴 수 있으니 문의하세요.
시간당 상한과 동시 열린 인보이스 상한은 둘 다 주소 풀을 보호합니다. 열린 인보이스는 각각 수신 주소 하나를 붙들고 있으므로, 어느 한 사이트에서 폭주하는 루프가 발생하면 모두의 풀이 고갈될 수 있습니다. 새 상점은 인보이스를 한 번에 20개까지 열어 둘 수 있고, 실제로 수금한 인보이스 하나마다 허용치가 1씩 늘어 최대 200개까지 올라갑니다. underpaid는 열린 것으로 셉니다 — 나머지를 기다리며 여전히 주소를 붙들고 있기 때문입니다. 버려진 인보이스를 취소하면 그 주소는 즉시 반환됩니다. 같은 idempotency_key로 한 재시도는 시간당 상한에 포함되지 않습니다.
요청 제한은 API 키당 분당 120회입니다 — 초당 두 번으로, 실제 어떤 주문 흐름보다도 넉넉합니다. 429에는 초 단위의 Retry-After 헤더가 실려 옵니다. 촘촘한 루프로 재시도하지 말고 그만큼 기다리세요. 그렇게 하지 않으면 제한 구간만 더 뒤로 밀립니다.
오류#
실제로 마주치게 될 상태 코드.
오류는 JSON으로 반환되며, 형태가 두 가지입니다. 저희나 처리 코어가 판단한 것은 detail 아래에 {code, message} 쌍을 넣습니다. 검증을 통과하지 못한 요청 본문은 그 자리에 필드 오류 목록을 대신 넣습니다. detail.code를 읽기 전에 어느 쪽을 받았는지 확인하세요 — 그리고 분기는 `code`로 하고 `message`로는 절대 하지 마세요. 문구는 언제든 바뀔 수 있지만 코드는 바뀌지 않습니다.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| 상태 | 발생 시점 | 할 일 |
|---|---|---|
| 401 | 키가 없거나, 잘못되었거나, 폐기됨. | 헤더를 확인하세요. 폐기된 경우 키를 재발급하세요. |
| 404 | 그런 인보이스가 없거나 다른 상점 소유임. | ID를 확인하세요. 두 경우가 똑같이 응답하는 것은 의도된 것으로, ID의 존재 여부를 떠볼 수 없게 하기 위함입니다. |
| 409 | 인보이스가 이를 금지하는 상태임. | 먼저 현재 상태를 확인하세요. |
| 422 | 요청 형식이 잘못되었거나, 금액이 인보이스 한도를 벗어났음. | 메시지에 전송된 값과 제한값이 모두 명시됩니다. |
| 429 | 이번 시간에 인보이스가 너무 많거나, 동시에 열린 것이 너무 많거나, 요청이 너무 많음. | Retry-After만큼 기다린 뒤 다시 시도하세요. |
| 502 | 처리 코어에 연결할 수 없었음. | 동일한 idempotency 키로 재시도하세요. |
코드
저희가 판단하는 쪽의 형태는 {"detail": {"code": …, "message": …}}입니다. 다음은 머천트 API가 반환하는 코드입니다.
| 코드 | 상태 | 의미 |
|---|---|---|
| invalid_api_key | 401 | 키가 없거나, 형식이 잘못되었거나, 알 수 없거나, 폐기되었습니다. 네 경우 모두 똑같이 응답하므로 키를 떠볼 수 없습니다. |
| not_found | 404 | 그런 객체가 없거나 다른 상점 소유입니다. |
| invalid_input | 422 | 요청이 코어의 검증을 통과하지 못했습니다 — 잘못된 금액, 소수점 자릿수 초과, 인보이스 한도를 벗어난 금액. |
| conflict | 409 | 동작이 현재 상태와 모순됩니다. 예를 들어 더 이상 열려 있지 않은 인보이스를 취소하려는 경우입니다. |
| too_many_requests | 429 | 속도 제한입니다: 시간당 인보이스, 동시에 열린 인보이스, 또는 분당 요청. Retry-After가 얼마나 기다려야 하는지 알려 줍니다. |
| cbc_unreachable | 502 | 처리 코어에 연결할 수 없었습니다. 동일한 idempotency_key로 재시도하세요. |
| webhook_url_rejected | 422 | 키를 저장할 때만 발생합니다: 웹훅 URL이 위의 검사를 통과하지 못했습니다. detail.reason이 어떤 규칙인지 알려 줍니다 — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials 등. |
502는 인보이스가 생성되지 않았다는 의미가 아닙니다 — 요청은 처리되었지만 응답이 돌아오는 도중 유실되었을 수 있습니다. 동일한 idempotency_key로 재시도하면 기존 인보이스나 새 인보이스 중 하나를 받게 되며, 절대 두 개가 생기지 않습니다.
Checkout: what the buyer sees#
The hosted payment page, and when to build your own.
payment_url points at https://paysell.me/pay/{invoice_id}. One page, no account, no login, mobile first, and nothing for you to build.
On the page
- Your shop's name, the amount and the coin, large, with the invoice's description underneath.
- A countdown to
expires_at— plus the 24-hour grace period when the invoice isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
환불#
고객에게 환불하는 방법.
환불은 API 호출이 아니라 지원팀을 통해 처리됩니다. 환불은 사람이 알려준 주소로 새로 돈을 보내는 일이고, API 호출 한 번에 돈을 되돌려 보내는 결제 처리 서비스는 공격자의 주소로 돈을 보내게 만들 수 있는 결제 처리 서비스입니다. 그래서 의도적으로 수동입니다.
구매자에게 환불하려면 계정 영역에서 지원 티켓을 열고 invoice_id 또는 tx_hash, 금액, 보낼 주소를 적어 주세요. 운영자가 결제를 확인하고 귀하의 잔액에서 돈을 옮긴 뒤 같은 티켓으로 답변합니다. 1분이 아니라 영업일 하루 정도 걸린다고 보시면 됩니다.
설계할 때 염두에 둘 결과가 둘 있습니다. 초과 입금은 전액 귀하에게 반영되며 — 저희는 한 푼도 갖지 않습니다 — 따라서 너무 많이 보낸 구매자에게 차액을 돌려줄지는 귀하의 판단이고, 같은 경로를 따릅니다. 그리고 부족 입금된 인보이스는 열려 있는 동안에는 환불 사안이 아닙니다. 돈은 귀하의 잔액에 있고, 주소는 여전히 감시 중이며, 구매자는 그냥 추가 입금하면 됩니다. 유예 기간이 지나 인보이스가 돈을 담은 채 expired가 되었을 때 비로소 결정할 일이 생깁니다.
테스트#
출시 전에 연동을 테스트하는 방법.
여기서 발급되는 키는 운영 키입니다: 발급되는 모든 키는 운영 코어와 TON 메인넷을 상대로 동작하는 sk_live_ 키입니다. 별도의 테스트 환경은 없으며, 여기에는 장점이 있습니다: 실제 주문이 지나갈 경로를 그대로 점검하게 됩니다.
그러니 실제 돈이 오가는 무엇이든 테스트하듯 테스트하세요. 소액으로 하는 것입니다. 최소 금액(0.1 TON 또는 3 USDT)으로 인보이스를 만들고, 자기 지갑에서 결제한 뒤 전체 경로를 지켜보세요 — 결제 페이지, 웹훅, 서명 검증, 주문이 결제 완료로 바뀌는 것까지. 수수료는 그대로 부과되고, 코인은 실제로 움직입니다.
돈을 쓰지 않고 확인할 수 있는 부분은 이렇습니다. 인보이스 생성과 조회, 취소, 잘못된 금액에 대한 422, 잘못된 키에 대한 401, 그리고 직접 만든 서명 검증 — 시크릿으로 샘플 본문에 서명해 자신의 핸들러에 넣어 보세요. 진짜 결제가 반드시 필요한 것은 마지막 단계 하나뿐입니다. 실제 payment.credited 웹훅입니다.
샌드박스나 결제 시뮬레이션에 의존하지 않도록 연동을 설계하세요: 실제 경로가 더 빠르고 — 더 정확하게 — 검증됩니다.
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
출시 전 체크리스트#
출시 전 점검 열 가지.
- 키는 서버 측에만 있으며, 브라우저 JavaScript에는 절대 없다.
- 웹훅 서명이
"{timestamp}.{raw_body}"에 대해 상수 시간으로 검증된다. - 5분보다 오래된 전달은 거부되고, 서버 시계는 NTP에 맞춰져 있다.
- 반복된
X-Paysell-Event-Id는 두 번째에는 아무 일도 하지 않는다. - 웹훅은 10초 이내에 2xx로 응답하고, 느린 작업은 그 이후에 수행된다.
- 웹훅 URL은 포트 443의 https:// 도메인이며, 그 앞에 리디렉션이 없다.
- 웹훅을 놓쳐도 견딜 수 있다: 감사 페이지나 대조 작업에서 인보이스 엔드포인트를 읽는다.
idempotency_key는 주문당 한 번 생성되어 재시도 시 재사용된다.- 금액은 일반 단위 문자열로 나가고, 웹훅의 숫자는 최소 단위로 읽는다.
- 주소는 반환된 그대로, 수정 없이 표시된다.
paid뿐 아니라overpaid와underpaid도 처리한다.expired에도paid_minor가 남아 있을 수 있다.- 상품은
status: paid또는overpaid로 내보내며, 콜백이 도착했다는 사실만으로는 절대 내보내지 않는다. 429는 즉시 재시도가 아니라Retry-After만큼 기다리는 것으로 처리한다.- 잔액은 저희로부터 읽어오며, 별도로 진실로서 추적하지 않는다.
명확하지 않은 부분이 있나요?
이 페이지가 질문에 답하지 못했다면, 그것은 문서의 공백이며 알려주실 가치가 있습니다. 계정 영역에서 문의해 주시면 답변뿐 아니라 페이지 자체를 수정하겠습니다.