이 문서 하나로 연동이 끝납니다. Launch 핸드셰이크부터 지갑 콜백·보안·한도까지 전부 여기 있고, 다른 문서를 참조하지 않습니다.
Crypto 패키지에는 게임 2종이 들어 있습니다. 둘은 같은 연동을 공유합니다 — Launch·지갑·보안이 동일하고 임베드 URL만 다릅니다. 한쪽만 붙였다가 다른 쪽을 추가할 때 추가 작업이 없습니다.
| 게임 | 무엇 | 임베드 |
|---|---|---|
| BTC 부스트 | 실시간 BTC 롱/숏 · 1~1000배 레버리지 | ?only=pro |
| 5분 업다운 | 5분 뒤 오를지 내릴지 · BTC / ETH | ?only=updown&asset=btc |
귀사 사용자는 GinzaPlay에 가입하지 않습니다. 귀사가 인증한 사용자를 서명된 토큰으로 넘기면 됩니다. 잔고도 귀사 것이 기준이고, GinzaPlay는 베팅 원장만 갖습니다.
카지노 게임은 보통 라운드가 눈앞에서 끝나므로 잠금(lock) → 정산 → 해제모델을 씁니다. 여기는 다릅니다 — 5분 업다운은 5분 뒤, 부스트 포지션은 며칠 뒤에 끝날 수 있고, 그 사이 사용자는 iframe을 닫습니다. 그래서 스포츠북과 같은 지연 정산(Seamless Wallet)을 씁니다.
베팅/진입 GinzaPlay ──▶ 귀사 /bet 스테이크 즉시 차감 (홀드 아님) 결과 확정 GinzaPlay ──▶ 귀사 /win 적립 · 낙첨이면 amount 0 · refTxUuid로 원 베팅 참조 차감 실패 GinzaPlay ──▶ 귀사 /rollback 되돌림 (무효·환불 포함) 기록 대조 GinzaPlay ──▶ 귀사 /status 3분마다 양쪽 기록 비교
모든 베팅은 정확히 한 번의 종결 콜백을 받습니다 — /win(낙첨·전액손실이면 amount 0) 또는 /rollback(무효). 귀사 원장에서 /bet만 있고 종결이 없는 라운드는 “아직 진행 중”이라는 뜻입니다.
첫 문의에서 오픈까지 다섯 단계입니다. 각 단계는 무엇이 끝나야 다음으로 넘어가는지로 적었습니다.
문의
담당자에게 연락. 사이트 주소·예상 규모·원하는 게임(부스트 / 5분 / 둘 다)을 알려주시면 됩니다.
검토
이메일 커뮤니케이션으로 진행합니다.
정보 상호 전달
아래 4절의 두 표를 채웁니다. 저희가 키를 발급하고, 귀사가 지갑 주소·출처·IP·한도를 알려주십니다. 키는 안전한 경로로 따로 전달합니다.
연동 테스트
샌드박스에서 전체 루프를 돌립니다. 귀사 지갑을 구현하기 전에도 레퍼런스 지갑으로 먼저 확인할 수 있습니다 (13절 체크리스트).
연동 완료 · 오픈
라이브 오퍼레이터로 전환하고 실제 출처·IP를 잠급니다. 이후 한도·환수율은 포털에서 직접 조정하십니다.
① GinzaPlay → 귀사
| 값 | 예시 | 성격 | 쓰임 |
|---|---|---|---|
| operator_id | op_yoursite | 공개 | 귀사 식별자. 임베드 URL의 ?op= 와 모든 요청 본문에 들어갑니다 |
| secret | 43자 랜덤 문자열 | 1급 비밀 | HMAC-SHA256 서명 키. 양방향(귀사→저희 launch, 저희→귀사 콜백)에 같은 키를 씁니다. 서버 환경변수에만 두세요 |
| 포털 비밀번호 | 계정 생성 시 1회 발급 | 비밀 | 관리 포털 로그인용. 첫 접속 후 포털에서 변경하세요 |
| 포털 주소 | /op/crypto | 공개 | 귀사 전용 백오피스 |
| 샌드박스 계정 | 테스트용 operator_id + 키 | 비밀 | 라이브 전 검증용. 레퍼런스 지갑이 붙어 있어 귀사 지갑 없이도 테스트됩니다 |
② 귀사 → GinzaPlay
| 값 | 예시 | 필수 | 쓰임 |
|---|---|---|---|
| walletUrl | https://api.yoursite.com/mc/wallet | 필수 | 지갑 콜백 베이스 URL. 뒤에 /balance /bet /win /rollback /status 가 붙습니다 (8절). HTTPS 필수 |
| allowedOrigins | https://*.yoursite.com | 선택 | 위젯을 임베드할 사이트 출처. 와일드카드·쉼표 나열 가능. 비워두면 임베드 제한을 걸지 않습니다 — 아래에서 고르세요 |
| ipAllowlist | 1.2.3.4, 5.6.7.8 | 라이브 필수 | Launch를 호출할 귀사 서버의 공인 IP. 샌드박스에서는 비워도 되지만, 라이브는 비면 전부 차단됩니다 (아래) |
| currency / 라벨 | KRW / 원 | 필수 | 정산 통화와 화면 표시 단위 |
| 한도 | 최소·최대 베팅, 최대 당첨, 미결 노출 상한 | 필수 | 12절 참조. 이후 포털에서 직접 조정합니다 |
| payoutRatePct | 100 | 선택 | 5분 업다운 환수율(%). 기본 100이며, 낮추면 그만큼 귀사 마진이 됩니다 |
| 기술 담당자 | 이메일 · 연락 수단 | 필수 | 지급 실패·기록 불일치 경보를 받을 곳 |
도메인은 두 곳에 쓰입니다 — 귀사 사이트 주소는 allowedOrigins(브라우저가 임베드를 허용할 출처), 귀사 API 주소는 walletUrl(저희 서버가 돈을 호출할 곳)입니다. 서로 다른 도메인이어도 됩니다.
임베드 출처 제한 — 두 가지 중 선택
브라우저가 임베드를 막는 유일한 수단은 frame-ancestors 헤더이고, 이건 도메인만 받습니다. IP로는 불가능합니다 — iframe을 불러오는 것은 엔드유저의 브라우저라, 그 요청에 귀사 서버 IP가 등장하지 않습니다.
| 등록 | 미등록 | |
|---|---|---|
| 아무 사이트가 임베드 | 막힘 | 가능 |
| 위조 토큰으로 세션 시작 | 401 | 401 |
| 사용한 토큰 재사용 | 401 | 401 |
| 관리 부담 | 도메인 바뀌면 갱신 | 없음 |
고르는 기준은 루트 도메인입니다. 서브도메인이 아무리 많아도 루트가 고정이면 등록이 한 줄로 끝나고, 반대로 루트가 여러 개이면서 자주 바뀌면 등록은 계속 따라다니는 작업이 됩니다. 후자라면 미등록이 맞습니다 — 갱신을 놓쳐 임베드가 죽는 쪽이 실제로 더 위험합니다.
등록하시는 경우 — 와일드카드가 됩니다. 서브도메인이 몇 개든, 새로 생겨도 재등록이 필요 없습니다. 루트 도메인이 여러 개면 쉼표로 나열하세요.
allowedOrigins: https://*.yoursite.com, https://www.other.co.kr → 응답 헤더: frame-ancestors 'self' https://*.yoursite.com https://www.other.co.kr
미등록으로 두시는 경우 — 이 업계의 일반적인 구성입니다. 임베드에는 여전히 유효한 1회용 launch 토큰이 필요하고 그건 귀사 백엔드만 발급하므로, 토큰 없이 붙인 위젯은 빈 화면이 뜹니다. 돈이 걸린 관문은 그대로입니다.
미등록일 때 남는 위험 좁히기
남는 위험이 launch URL 유출 하나뿐이므로, 그 URL이 브라우저 밖으로 나가지 않게 하는 것으로 대부분 닫힙니다. 서버 비용도 추가 작업도 들지 않습니다.
src에 넣으세요. 미리 만들어 두면 쓰이기 전까지 120초가 흘러갑니다.③ 귀사 방화벽에 열어주실 IP
지갑 콜백은 저희 서버 두 대에서 나갑니다(웹·정산). walletUrl로 들어오는 연결을 제한하신다면 두 IP를 모두 허용해 주세요 — 두 주소는 온보딩 시 이메일로 개별 전달드립니다.
403 ip_not_allowed로 떨어지고, 바뀐 것은 모드 하나뿐이라 원인을 찾기 어렵습니다. 전환 전에 등록하세요.사용자가 게임을 열 때 귀사 백엔드가 호출합니다. 브라우저에서 직접 부르면 안 됩니다 — 서명 키가 노출됩니다.
POST /api/op/v1/launch — 요청 본문
{
"operator_id": "op_yoursite",
"user": "귀사 사용자 고유 ID",
"token": "귀사 사용자 세션 토큰 — 지갑 콜백 때 그대로 돌려드립니다",
"currency": "KRW",
"lang": "ko", // ko | en | ja
"timestamp": 1785500000000, // 유닉스 ms · ±5분 벗어나면 거절
"nonce": "1회성 임의값" // 8~200자 · 10분 내 재사용 거절
}본문 원문 문자열 그대로를 secret으로 HMAC-SHA256 하고, hex 결과를 X-MC-Signature 헤더에 넣습니다. 직렬화한 문자열과 실제 전송 본문이 한 글자라도 다르면 서명이 깨집니다.
200 OK — 응답
{
"url": "https://ginzaplay.com/widget?op=op_yoursite#lt=lt_xxxxx&lang=ko",
"lt": "lt_xxxxx"
}url을 직접 조립하지 마세요 — 받은 값을 그대로 쓰세요. ?op=가 빠지면 위젯이 “임베드 주소에 op 파라미터가 없습니다”로 멈춥니다. lt는 프래그먼트(#)에 있어야 하고요 — 프래그먼트는 Referer 헤더에도 서버 접근 로그에도 남지 않습니다.| 성질 | 값 |
|---|---|
| lt 유효기간 | 120초 · 1회용 (두 번째 교환은 invalid_or_used_token) |
| 세션 유효기간 | 12시간 |
| 세션 전달 | 쿠키 아님 — X-MC-Session 헤더 (3rd-party 쿠키 차단 환경 대응) |
| timestamp 허용 | ±5분 |
| nonce 기억 | 10분 |
거절 응답
| HTTP | error | 원인 |
|---|---|---|
| 401 | bad_signature | 서명 불일치 (본문 문자열 확인) |
| 401 | unknown_operator | operator_id 오타 또는 비활성 |
| 401 | stale_or_replayed | timestamp 5분 초과 또는 nonce 재사용 |
| 403 | ip_not_allowed | 등록 IP 밖에서 호출 |
| 403 | operator_suspended | 계정 정지 |
| 429 | rate_limited | 분당 600회 초과 |
lt는 iframe이 알아서 세션으로 교환합니다. 프론트에서 직접 교환해야 할 때만 POST /api/op/v1/session에 { lt, op }를 보내 { session }을 받으세요.
Node.js — 위젯 URL 받기
import crypto from "crypto";
const OPERATOR_ID = "op_yoursite";
const SECRET = process.env.MC_SECRET; // 온보딩 시 발급
const MC_BASE = "https://ginzaplay.com";
export async function getWidgetUrl(user, userToken) {
const body = {
operator_id: OPERATOR_ID,
user, // 귀사 사용자 ID
token: userToken, // 귀사 사용자 세션 토큰
currency: "KRW",
lang: "ko",
timestamp: Date.now(),
nonce: crypto.randomUUID(),
};
// 서명 대상은 '보내는 본문 문자열' 그 자체 — 다시 stringify 하지 마세요.
const payload = JSON.stringify(body);
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("hex");
const res = await fetch(MC_BASE + "/api/op/v1/launch", {
method: "POST",
headers: { "content-type": "application/json", "X-MC-Signature": sig },
body: payload,
});
if (!res.ok) throw new Error("launch failed: " + (await res.text()));
const { url } = await res.json();
return url; // 그대로 iframe src 로. 재조립 금지 (?op= 가 필요합니다)
}게임을 고르려면 — 받은 url에 only 파라미터만 추가
function withGame(url, game) {
const u = new URL(url); // 프래그먼트(#lt=)는 그대로 보존됩니다
if (game === "boost") u.searchParams.set("only", "pro");
if (game === "btc5") { u.searchParams.set("only", "updown"); u.searchParams.set("asset", "btc"); }
if (game === "eth5") { u.searchParams.set("only", "updown"); u.searchParams.set("asset", "eth"); }
u.searchParams.set("skin", "minimal"); // 선택
return u.toString();
}<iframe src="https://ginzaplay.com/widget?op=op_yoursite&only=pro#lt=LAUNCH_TOKEN" style="width:100%;height:100%;border:0" allow="clipboard-write" title="GinzaPlay BTC 부스트" ></iframe>
| 파라미터 | 값 | 설명 |
|---|---|---|
| op | operator_id | 필수. Launch 응답에 이미 들어 있습니다 |
| only | pro · updown | 생략하면 미니사이트(게임 목록 포함)로 뜹니다 |
| asset | btc · eth | only=updown 일 때만 |
| skin | 아래 표 | 생략 시 terminal |
| #lt | launch 토큰 | 반드시 프래그먼트. 쿼리에 두지 마세요 |
skin
| skin | 이름 | 느낌 |
|---|---|---|
| terminal | 터미널 | 다크 트레이딩 터미널 톤 · skin 생략 시 기본 |
| exchange | 익스체인지 | 거래소풍 다크 |
| minimal | 미니멀 / 라이트 | 밝은 화이트 톤 |
| game | 게임 / 네온 | 네온 액센트 |
크기 — 반응형이라 폭 320px부터 동작합니다. 세로는 임베드가 스스로 스크롤하므로 고정 높이(예: 100dvh 또는 컨테이너 높이)를 주세요. 부모 페이지가 늘어나지 않습니다.
GinzaPlay → 귀사 walletUrl. 등록한 베이스 URL 뒤에 아래 경로가 붙습니다. 모든 호출은 Launch와 같은 방식·같은 키로 서명되어 오고 (X-MC-Signature), 검증은 필수입니다. 금액은 전부 정수 minor unit(1원 = 1)이며 소수점은 없습니다.
| 경로 | 언제 | 하는 일 |
|---|---|---|
| POST /balance | 위젯 로드·주기적 | 현재 잔고 조회 |
| POST /bet | 베팅·포지션 진입 | 차감. 부족하면 NOT_ENOUGH_MONEY |
| POST /win | 정산·청산·캐시아웃 | 적립. 낙첨·전액손실이면 amount 0 (라운드 종결 신호). refTxUuid로 원 베팅 참조 |
| POST /rollback | 차감 후 실패 | 그 차감을 되돌림 |
| POST /status | 3분마다 | 이 거래들을 갖고 계신지 — 양쪽 기록 대조 |
공통 필드
| 필드 | 타입 | 설명 |
|---|---|---|
| operator_id | string | 귀사 식별자 |
| currency | string | 온보딩 시 등록한 통화 |
| request_uuid | string | 이 호출의 ID — 응답에 그대로 echo 하세요 |
| user | string | 귀사 사용자 ID |
| token | string | Launch 때 넘긴 세션 토큰 (balance·bet에만 옵니다) |
| transaction_uuid | string | 멱등키 — 이 값으로 중복을 막으세요 |
| reference_transaction_uuid | string? | win·rollback이 참조하는 원래 bet의 uuid |
| round | string | 마켓/라운드 식별자 — 5분은 내부 마켓 ID, 부스트는 pro:<포지션ID> |
| amount | integer | minor unit. rollback에는 없습니다 |
표시용 선택 필드 (bet · win)
귀사 베팅내역 UI를 위한 참고 정보입니다. 정산·검증에는 위의 필수 필드만 쓰시고, 아래는 없거나 null일 수 있다는 전제로 저장만 해두세요. 가격은 USD(체인링크·정산 기준)와 원화 트윈(_krw)이 함께 갑니다 — 원화 값은 게임 화면과 동일한 고정 환율로 계산되어 유저가 본 숫자와 정확히 일치합니다.
| 필드 | 값 | 설명 |
|---|---|---|
| game | updown · pro | 게임 구분 (5분 / BTC 부스트) |
| asset | btc · eth · sol · xrp | 5분·부스트의 기초자산 |
| market_slug | btc-updown-5m-1786286700 | 회차 슬러그 — 끝 숫자가 시작 epoch(초) |
| round_start_at | ISO 8601 (UTC) | 회차 시작 — 귀사 타임존으로 포맷해 "07:20 회차"로 표시 |
| round_end_at | ISO 8601 (UTC) | 회차 종료 |
| side | up · down · long · short | 베팅 방향 |
| odds | number (예: 1.6458) | 확정 배당배율 = 적중 지급 ÷ 베팅액 (환수율 반영) |
| entry_price | number (USD) | 베팅/진입 시점 가격 |
| target_price | number (USD) | 5분 회차 타겟. 예정 회차 베팅은 null (아직 미확정) |
| entry_price_krw · target_price_krw | integer (원) | 위 USD × fx_rate — 게임 화면에 표시된 바로 그 원화 금액 |
| fx_rate | number | 고정 표시 환율 (게임 상수). 정산에는 쓰이지 않습니다 |
| leverage | integer | 부스트 전용 |
응답 — 모든 콜백 공통
{ "status": "RS_OK", "balance": 990000, "request_uuid": "req_…" }상태 코드
RS_OK · 정상RS_ERROR_NOT_ENOUGH_MONEY · 잔고 부족RS_ERROR_DUPLICATE_TRANSACTION · 멱등 중복RS_ERROR_TRANSACTION_DOES_NOT_EXIST · 참조한 bet 없음RS_ERROR_INVALID_SIGNATURE · 서명 오류RS_ERROR_LIMIT_REACHED · 한도 초과RS_ERROR_USER_DISABLED · 정지된 사용자RS_ERROR_WRONG_SYNTAX · 형식 오류POST /status — 기록 대조 전용 (요청/응답)
요청 { operator_id, currency, request_uuid, transaction_uuids: ["tx_a", "tx_b", …] }
응답 { "status": "RS_OK", "request_uuid": "req_…",
"transactions": [
{ "transaction_uuid": "tx_a", "found": true, "amount": 10000, "type": "bet" },
{ "transaction_uuid": "tx_b", "found": false }
] }Node.js (Express) — 콜백 핸들러 요지
app.post("/wallet/:action", async (req, res) => {
// 1) 서명 검증 — 원문 바디로. JSON.parse 후 다시 stringify 하면 깨집니다.
if (!verifyHmac(req.rawBody, req.header("X-MC-Signature"))) {
return res.json({ status: "RS_ERROR_INVALID_SIGNATURE" });
}
const b = req.body;
// 2) 멱등 — 이미 처리한 uuid면 '그때의 결과'를 그대로 반환. 다시 반영 금지.
const prev = await findTxn(b.transaction_uuid);
if (prev) return res.json({ ...prev.result, request_uuid: b.request_uuid });
if (req.params.action === "bet") {
const bal = await getBalance(b.user);
if (bal < b.amount)
return res.json({ status: "RS_ERROR_NOT_ENOUGH_MONEY", balance: bal,
request_uuid: b.request_uuid });
const after = await debit(b.user, b.amount, b.transaction_uuid, b.round);
return res.json(save(b, { status: "RS_OK", balance: after }));
}
if (req.params.action === "win") {
// 참조된 bet이 실제로 존재할 때만 적립하세요.
// (user, refTxUuid)만 믿고 적립하면 정산 위조에 그대로 노출됩니다.
const bet = await findTxn(b.reference_transaction_uuid);
if (!bet) return res.json({ status: "RS_ERROR_TRANSACTION_DOES_NOT_EXIST",
request_uuid: b.request_uuid });
const after = await credit(b.user, b.amount, b.transaction_uuid, bet.id);
return res.json(save(b, { status: "RS_OK", balance: after }));
}
if (req.params.action === "rollback") {
const bet = await findTxn(b.reference_transaction_uuid);
// 되돌릴 차감이 없으면 이 코드로 답하세요 → '정상 종료'로 처리됩니다.
if (!bet) return res.json({ status: "RS_ERROR_TRANSACTION_DOES_NOT_EXIST",
request_uuid: b.request_uuid });
const after = await revert(b.user, bet, b.transaction_uuid);
return res.json(save(b, { status: "RS_OK", balance: after }));
}
});/win·/rollback이 실패하거나 응답이 끊기면 같은 transaction_uuid로 30초마다 성공할 때까지 다시 호출합니다. 반복 호출을 예외가 아니라 정상 흐름으로 처리하세요 — 멱등이 되어 있으면 두 번 반영되지 않습니다./rollback의 참조 차감이 없다면 RS_ERROR_TRANSACTION_DOES_NOT_EXIST를 주세요. “애초에 차감이 없었으니 되돌릴 것도 없음 = 정상 종료”로 처리합니다. 다른 에러를 주면 영원히 재시도합니다./status로 최대 100건씩 묶어 물어보고, 귀사에 없는 차감·적립이 있으면 경보로 잡습니다. 지갑이 응답하지 않는 것과 “없다”고 답하는 것은 다르게 처리하니, 불확실할 때는 응답하지 마세요.(user, refTxUuid)만으로 승인하면 정산 위조가 됩니다.allowedOrigins는 선택이며, 미등록을 고르셨다면 그 상태가 의도된 것인지만 확인하세요.한도 (오퍼레이터별 설정)
| 항목 | 기본 | 설명 |
|---|---|---|
| 최소 베팅 | 1,000원 | 이 미만은 거절 |
| 최대 베팅 | 설정값 | 베팅 1건당 스테이크 상한 |
| 최대 당첨 | 1,000만원 | 1건이 지급받을 수 있는 최대 — 초과 베팅은 거절 |
| 미결 노출 상한 | 설정값 | 귀사 전체 미결 지급의무 합계 상한 |
| 부스트 포지션 | 동시 20개 | 사용자당 |
5분 업다운
BTC 부스트
one_way_mode로 거부되며, 방향을 바꾸려면 먼저 청산해야 합니다. 같은 방향 추가(최대 20건)와 청산·축소는 항상 가능합니다. 거래소의 원웨이 모드와 동일합니다.ginzaplay.com/op/crypto — operator_id + 비밀번호로 로그인합니다. 비밀번호를 설정하기 계정 생성 시 발급되며, 첫 접속 후 포털에서 바꾸실 수 있습니다. 등록된 IP에서만 접속됩니다.
GinzaPlay가 레퍼런스 지갑을 호스팅합니다. 귀사 지갑을 구현하기 전에도 launch → 차감 → 정산 적립 → 재시도까지 전체 루프를 돌려볼 수 있습니다. 아래가 전부 확인되면 라이브로 전환합니다.