개발자 문서
Kem Business API로 USDT 결제를 받기 위해 필요한 모든 것: 인증, 결제 엔드포인트, 그리고 주문 상태를 이끄는 서명된 웹훅.
인증
대시보드의 「API 키」에서 키를 만들고, 모든 요청에 시크릿 키를 Bearer token으로 보내세요. 테스트 키(sk_test_…)는 테스트 결제를 만들고, 라이브 키(sk_live_…)는 실제 돈을 움직입니다.
Authorization: Bearer sk_live_... # or sk_test_...
Base URL: https://api-business.kemapp.ioComplete business verification (KYB) before creating payments. In the current API this applies to both test and live keys, even though a test key can be created earlier. Owners, admins, developers and custom roles with API-key management permission can create keys; select the intended organization first.
Call this API from your server. Each key belongs to one business and one mode; no X-Org header is needed. Reads return only that key's mode. Keep secrets in your server's secret store, and revoke compromised keys in the dashboard. Dashboard sessions and their private endpoints use a separate authentication flow.
결제
결제를 생성하고 고객을 호스팅된 checkout_url로 보내세요 — 지갑·네트워크·확인은 Kem이 처리합니다. 결제를 조회하거나, 더 좋게는 웹훅으로 상태를 추적하세요.
/v1/paymentscurl -X POST https://api-business.kemapp.io/v1/payments \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": "25.000000",
"currency": "USDT",
"merchant_reference": "order-1042",
"customer_email": "[email protected]",
"description": "Pro plan",
"return_url": "https://yourshop.example/thanks",
"metadata": {
"order_id": "1042"
}
}'{
"id": "pay_example",
"status": "pending",
"mode": "test",
"amount": "25.000000",
"amount_minor": 25000000,
"amount_refunded_minor": 0,
"fee_bps": null,
"fee_minor": null,
"net_minor": null,
"currency": "USDT",
"network": null,
"merchant_reference": "order-1042",
"customer_email": "[email protected]",
"description": "Pro plan",
"return_url": "https://yourshop.example/thanks",
"metadata": {
"order_id": "1042"
},
"checkout_url": "https://dashboard.business.kemapp.io/pay/pay_example",
"checkout_expires_at": "2026-09-22T13:30:00+00:00",
"transaction_id": null,
"detected_at": null,
"confirmation_pending": false,
"created_at": "2026-09-22T13:00:00+00:00",
"updated_at": "2026-09-22T13:00:00+00:00"
}Returns HTTP 201. Example ids are placeholders. Save the returned id, mode and order reference, then use the returned checkout_url rather than constructing a checkout address.
- amount
- Required decimal string, e.g. 25 or 25.000000. Positive, at most six decimal places; no JSON number or scientific notation.
- currency
- USDT; defaults to USDT when omitted.
- merchant_reference
- Optional order/reference string. Store the returned payment id against your own order.
- customer_email
- Optional valid customer email.
- description
- Optional description displayed with the payment.
- return_url
- Optional destination after checkout. A browser redirect is not proof of successful payment.
- metadata
- Optional JSON object stored on the payment and returned by payment reads. It is not currently copied into webhook payloads.
Idempotency-Key — 선택이지만 권장 — 기본 24시간 이내에 같은 키와 요청 본문으로 재시도하면 원래 응답이 반환됩니다.
The default retention window is 24 hours and keys are scoped to the business. Within that window, the same key and body replay the original response; a changed body returns 409. After expiry, reuse can create another payment. Keep test and live idempotency keys distinct, and use a fresh key for each new order or simulation scenario.
/v1/payments— query: status, limit (1–100, default 20), cursor · returns has_more, next_cursor/v1/payments/{id}curl "https://api-business.kemapp.io/v1/payments?limit=20&status=succeeded" \
-H "Authorization: Bearer sk_test_..."List responses contain data, has_more and next_cursor. Pass next_cursor as cursor on the next request, retaining your filters. Both reads return HTTP 200. A payment belonging to another business or mode is returned as payment_not_found.
amount — decimal string with 6 places in responses ("25.000000"); amount_minor — the same value as an integer in micro-USDT.
fee_minor and net_minor are null while a payment is in flight and are frozen when it succeeds. Reconcile your payout against net_minor, not amount_minor — the difference is our fee (fee_bps).
결제 라이프사이클
pending → detected → succeeded · failed · expired · refunded (legacy)
pending — 결제 대기 · detected — 확인 중 · succeeded — 완료(live 모드에서만 주문 이행) · failed / expired — 종결. 과거 결제에는 refunded 상태가 있을 수 있습니다.
The default checkout window is 30 minutes. A pending payment with a submitted transaction hash gets a default 24-hour grace period after that window; confirmation_pending and payment.confirmation_delayed flag the delay. Detected payments wait for settlement or a terminal failure. Neither a hash, a redirect, nor a delayed-confirmation event proves payment. Use the payment's status and your stored mode before fulfilling an order.
오류와 한도
모든 오류는 하나의 envelope을 씁니다. 코드는 안정적입니다 — 메시지가 아니라 error.code로 분기하세요.
HTTP 4xx/5xx
{
"error": {
"code": "idempotency_conflict",
"message": "Idempotency-Key reused with a different request"
}
}| 401 unauthorized | missing or invalid API key |
| 403 kyb_required | business verification is required; current payment creation requires approved KYB for test and live keys |
| 403 forbidden | the action is not allowed, such as simulating with a live key |
| 404 payment_not_found | unknown payment id (or a live id with a test key) |
| 409 idempotency_conflict | Idempotency-Key reused with a different body — identical retries replay the original response |
| 422 validation_error | request-schema validation failed, such as a missing field or malformed customer_email |
| 422 invalid_amount | amount must be a positive decimal string with at most six decimal places, within the supported integer range |
| 422 invalid_currency | payment creation supports USDT only |
| 422 amount_below_minimum | amount is below the platform deposit minimum; the error message supplies the current minimum |
| 422 invalid_return_url | the return URL uses a disallowed scheme |
| 422 invalid_event | unsupported simulation event; use succeeded, failed or expired |
| 429 rate_limited | API keys share a default budget of 100 requests/minute across reads and writes; respect Retry-After |
| 500 internal_error | unexpected server error; retry payment creation with the same Idempotency-Key and body |
상품, 재사용 결제 링크와 웹훅 엔드포인트는 공개 API 키 엔드포인트가 아닌 대시보드에서 설정합니다. 현재 대시보드와 판매자 API에는 판매자가 환불을 시작하는 기능이 없습니다.
웹훅
대시보드 「Webhooks」에서 엔드포인트와 이벤트를 설정하세요. whsec_… 서명 시크릿은 생성 시 반환되며 권한이 있는 사용자는 대시보드에서 다시 확인할 수 있습니다.
| payment.created | A payment intent was created (API or checkout link). |
| payment.detected | The transfer was seen on-chain / in the KEM ledger; confirming. |
| payment.succeeded | The payment reached success. Fulfill only for a live payment associated with your order; simulation emits this event too. |
| payment.failed | The payment failed. |
| payment.expired | The checkout window closed without payment. |
| payment.confirmation_delayed | A transaction hash was submitted but remains unconfirmed past the checkout window. Hold the order; this is not proof of payment. |
| payment.refund.created | Reserved/legacy refund event. The current merchant API and dashboard do not expose refund initiation. |
| payment.refund.succeeded | A previously initiated refund completed. Current integrations cannot initiate a merchant refund. |
| reconciliation.exception | A reconciliation exception; this non-payment event has its own data object. |
| webhook.test | A test delivery from the dashboard. data contains a message rather than a payment. |
이벤트 페이로드
{
"id": "evt_example",
"type": "payment.succeeded",
"created": "2026-09-22T13:04:12+00:00",
"data": {
"payment": {
"id": "pay_example",
"status": "succeeded",
"amount_minor": 25000000,
"currency": "USDT",
"merchant_reference": "order-1042",
"network": null,
"product_id": null,
"payment_link_id": null,
"fee_minor": 0,
"net_minor": 25000000
}
}
}This is a zero-fee success example. fee_minor and net_minor are nullable before success. Payment webhooks currently omit mode, customer_email and the original metadata. Keep your own payment-id-to-order/mode mapping; retrieve an unknown payment with the appropriate key before acting. A simulated payment.succeeded must not fulfill a live order.
서명 검증
모든 전송에는 Kem-Signature가 있습니다. 「<t>.」와 원본 요청 바이트의 HMAC을 상수 시간으로 검증하고, 현재와 300초 넘게 차이 나는 타임스탬프를 거부하며 이벤트 id의 중복 처리를 막으세요.
Kem-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>
signed_input = "<t>." + raw_request_body
v1 = HMAC_SHA256(endpoint_secret, signed_input) # secret: whsec_...
# reject replays: drop the delivery when |now - t| > 300s
# also deduplicate the event body id before applying business effectsimport crypto from "node:crypto";
export function verifyKemSignature(rawBody, header, secret) {
if (!Buffer.isBuffer(rawBody) || typeof header !== "string") return false;
const match = /^t=([0-9]{1,16}),v1=([0-9a-fA-F]{64})$/.exec(header.trim());
if (!match) return false;
const timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(match[1] + ".")
.update(rawBody) // exact bytes, before JSON parsing
.digest();
const received = Buffer.from(match[2], "hex");
return crypto.timingSafeEqual(expected, received);
}import hashlib
import hmac
import re
import time
def verify_kem_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not isinstance(header, str):
return False
match = re.fullmatch(r"t=([0-9]{1,16}),v1=([0-9a-fA-F]{64})", header.strip())
if match is None:
return False
if abs(time.time() - int(match[1])) > 300:
return False
expected = hmac.new(
secret.encode(), (match[1] + ".").encode() + raw_body, hashlib.sha256
).digest()
return hmac.compare_digest(expected, bytes.fromhex(match[2]))Verify before parsing JSON: use a Buffer from express.raw in Node, or raw request bytes such as request.get_data() in Flask. Do not reserialize a parsed object. These examples reject malformed headers, invalid signatures, and timestamps over five minutes in the past or future. Keep the receiver's clock synchronized.
전송과 재시도
2xx로 빠르게 응답하세요. 실패한 전송은 백오프로 재시도된 뒤 데드레터 처리됩니다:
1m · 5m · 30m · 2h · 12h · 24h
There are seven attempts including the initial delivery. The delays above apply between retries. Deliveries may repeat or arrive out of order: deduplicate by the top-level event id, durably record or queue the event before acknowledging it, and keep fulfillment idempotent per payment. The timestamp/signature is refreshed on each retry. Review failed deliveries in Webhooks; use a payment read when you need its latest state.
테스트
KYB 승인 후 테스트 키로 결제를 만들고 succeeded, failed 또는 expired를 시뮬레이션하세요. 실제 이체 없이 결제 이벤트와 웹훅을 테스트할 수 있습니다:
curl -X POST https://api-business.kemapp.io/v1/payments/{id}/_simulate/succeeded \
-H "Authorization: Bearer sk_test_..."Replace {id} with a test payment id. Supported event names are succeeded, failed and expired; live keys are rejected. Create a fresh payment for each terminal outcome. Use simulation for test payments rather than sending real funds. Test and live payments can notify the same subscribed webhook endpoint, so retain the mode from creation.