Halaman dokumentasi
Layar yang membuat penjelasan panjang tetap terbaca — daftar isinya selalu menunjukkan posisi pembaca.
Pratinjau langsung
Sumber
<script>document.body.className="dc-bleed"</script>
<div class="dc-doc">
<header class="dc-doc__bar">
<a class="dc-doc__logo" href="#dc-doc-home">결산kr 개발 문서</a>
<form class="dc-doc__search" action="#dc-doc-home" method="get" role="search">
<label class="dc-sr" for="dc-doc-q">문서 검색</label>
<input class="dc-doc__search-input" id="dc-doc-q" type="search" name="q" placeholder="문서 검색">
</form>
<a class="dc-doc__key" href="#dc-doc-keys">API 키 보기</a>
</header>
<div class="dc-doc__body">
<article class="dc-doc__article">
<nav class="dc-doc__crumb" aria-label="현재 위치">
<a class="dc-doc__crumb-link" href="#dc-doc-home">문서</a>
<span class="dc-doc__crumb-sep" aria-hidden="true">/</span>
<a class="dc-doc__crumb-link" href="#dc-doc-pay">결제 연동</a>
<span class="dc-doc__crumb-sep" aria-hidden="true">/</span>
<span class="dc-doc__crumb-now" aria-current="page">웹훅 받기</span>
</nav>
<h1 class="dc-doc__h1">웹훅 받기</h1>
<p class="dc-doc__meta">2026년 8월 21일 고침 · 읽는 데 6분 · 한지우</p>
<p class="dc-doc__lead">결제가 끝나거나 환불이 일어나면 결산kr 서버가 정해 둔 주소로 POST 요청을 보냅니다. 주소를 등록하고, 서명을 검증하고, 같은 이벤트가 두 번 왔을 때를 처리하는 순서를 차례로 설명합니다.</p>
<h2 class="dc-doc__h2" id="dc-doc-register">1. 받을 주소 등록</h2>
<p class="dc-doc__p">콘솔의 개발자 · 웹훅 화면에서 주소를 넣습니다. https 주소만 받고, 포트는 443 만 씁니다. 주소 하나에 여러 이벤트를 묶어도 되고, 이벤트별로 다른 주소를 써도 됩니다.</p>
<ol class="dc-doc__ol">
<li class="dc-doc__li">콘솔에서 <b>개발자 · 웹훅 · 주소 추가</b> 를 누릅니다.</li>
<li class="dc-doc__li">받을 이벤트를 고릅니다. 처음에는 결제 완료와 환불 완료 두 개면 충분합니다.</li>
<li class="dc-doc__li">저장하면 비밀키가 한 번만 보입니다. 그 자리에서 환경 변수에 옮겨 두세요.</li>
</ol>
<div class="dc-doc__note">
<p class="dc-doc__note-title">테스트 키로 먼저 해 보세요</p>
<p class="dc-doc__note-text">테스트 환경과 운영 환경은 비밀키가 다릅니다. 테스트 주소에서 이벤트 스무 건을 흘려 본 뒤 운영 주소를 등록하는 편이 빠릅니다.</p>
</div>
<h2 class="dc-doc__h2" id="dc-doc-verify">2. 서명 검증</h2>
<p class="dc-doc__p">본문을 받은 그대로(파싱 전 원본 바이트) 비밀키로 HMAC-SHA256 해서 Fs-Signature 헤더와 견줍니다. 다르면 400 을 돌려주고 아무것도 처리하지 않습니다.</p>
<pre class="dc-doc__code"><code class="dc-doc__code-text">const crypto = require("crypto");
app.post("/hooks/fs", (req, res) => {
const mine = crypto
.createHmac("sha256", process.env.FS_WEBHOOK_SECRET)
.update(req.rawBody)
.digest("hex");
if (mine !== req.header("Fs-Signature")) {
return res.status(400).send("signature mismatch");
}
enqueue(req.body); // 처리는 큐에 맡기고
res.status(200).send("ok"); // 응답은 바로 돌려준다
});</code></pre>
<h3 class="dc-doc__h3" id="dc-doc-header">보내는 헤더</h3>
<dl class="dc-doc__dl">
<dt class="dc-doc__dt">Fs-Signature</dt>
<dd class="dc-doc__dd">본문을 비밀키로 서명한 값. 16진수 소문자입니다.</dd>
<dt class="dc-doc__dt">Fs-Timestamp</dt>
<dd class="dc-doc__dd">보낸 시각(초). 5분이 지난 요청은 받지 말고 버리세요.</dd>
<dt class="dc-doc__dt">Fs-Event-Id</dt>
<dd class="dc-doc__dd">이벤트 하나의 고유값. 같은 값이 다시 오면 이미 처리한 건입니다.</dd>
</dl>
<h2 class="dc-doc__h2" id="dc-doc-retry">3. 재시도와 중복</h2>
<p class="dc-doc__p">20초 안에 200 이 돌아오지 않으면 실패로 보고 다시 보냅니다. 간격은 1분, 5분, 30분, 2시간, 6시간이고 그 뒤에는 멈춥니다.</p>
<ul class="dc-doc__ul">
<li class="dc-doc__li">응답을 먼저 돌려주고, 무거운 일은 큐에 넘기세요. 재고 차감을 응답 안에서 하면 재시도가 쌓입니다.</li>
<li class="dc-doc__li">Fs-Event-Id 를 테이블에 유일 키로 저장해 두 번째 요청은 그냥 200 으로 받아 주세요.</li>
<li class="dc-doc__li">실패가 이어지면 등록한 메일로 알립니다. 콘솔의 전송 기록에서 본문을 그대로 다시 보낼 수 있습니다.</li>
</ul>
<footer class="dc-doc__foot">
<div class="dc-doc__helpful">
<p class="dc-doc__helpful-q">이 문서가 도움이 되었나요?</p>
<div class="dc-doc__helpful-act">
<button class="dc-doc__vote" type="button">도움이 됐어요</button>
<button class="dc-doc__vote" type="button">부족해요</button>
</div>
</div>
<nav class="dc-doc__around" aria-label="앞뒤 문서">
<a class="dc-doc__around-link" href="#dc-doc-prev"><span class="dc-doc__around-dir">이전 문서</span><span class="dc-doc__around-name">결제 요청 만들기</span></a>
<a class="dc-doc__around-link dc-doc__around-link--next" href="#dc-doc-next"><span class="dc-doc__around-dir">다음 문서</span><span class="dc-doc__around-name">정산 내역 조회 API</span></a>
</nav>
</footer>
</article>
<aside class="dc-doc__side">
<nav class="dc-doc__toc" aria-label="이 문서의 목차">
<p class="dc-doc__toc-title">이 문서 안에서</p>
<ul class="dc-doc__toc-list">
<li><a class="dc-doc__toc-link dc-doc__toc-link--on" href="#dc-doc-register" aria-current="true">1. 받을 주소 등록</a></li>
<li><a class="dc-doc__toc-link" href="#dc-doc-verify">2. 서명 검증</a></li>
<li><a class="dc-doc__toc-link dc-doc__toc-link--sub" href="#dc-doc-header">보내는 헤더</a></li>
<li><a class="dc-doc__toc-link" href="#dc-doc-retry">3. 재시도와 중복</a></li>
</ul>
<a class="dc-doc__edit" href="#dc-doc-edit">이 문서 고치기 제안</a>
</nav>
</aside>
</div>
</div>
.dc-doc { font-family:var(--dc-font); background:var(--dc-bg); color:var(--dc-text); }
.dc-doc__bar { position:sticky; top:0; z-index:5; display:flex; align-items:center; gap:var(--dc-4); padding:var(--dc-3) var(--dc-6); border-bottom:1px solid var(--dc-line); background:var(--dc-surface); }
.dc-doc__logo { font-size:16px; font-weight:800; letter-spacing:-.02em; color:var(--dc-text); text-decoration:none; }
.dc-doc__search { flex:1; max-width:320px; margin-left:auto; }
.dc-doc__search-input { width:100%; min-height:40px; padding:0 var(--dc-3); border:1px solid var(--dc-line-field); border-radius:var(--dc-r-sm); background:var(--dc-surface); color:var(--dc-text); font-size:14px; }
.dc-doc__search-input::placeholder { color:var(--dc-text-mute); }
.dc-doc__search-input:focus { border-color:var(--dc-brand); box-shadow:0 0 0 3px var(--dc-brand-soft); }
.dc-doc__key { display:inline-flex; align-items:center; min-height:40px; padding:0 var(--dc-4); border:1px solid var(--dc-line-field); border-radius:var(--dc-r-sm); color:var(--dc-text); font-size:14px; font-weight:600; text-decoration:none; }
.dc-doc__key:hover { background:var(--dc-sunken); }
.dc-doc__body { display:grid; grid-template-columns:minmax(0,72ch) 236px; justify-content:center; gap:var(--dc-7); align-items:start; padding:var(--dc-6) var(--dc-6) var(--dc-8); }
.dc-doc__article { max-width:72ch; min-width:0; }
.dc-doc__crumb { display:flex; align-items:center; gap:var(--dc-2); font-size:13px; color:var(--dc-text-sub); }
.dc-doc__crumb-link { color:var(--dc-text-sub); text-decoration:none; }
.dc-doc__crumb-link:hover { color:var(--dc-text); text-decoration:underline; }
.dc-doc__crumb-sep { color:var(--dc-text-mute); }
.dc-doc__crumb-now { color:var(--dc-text); font-weight:600; }
.dc-doc__h1 { margin-top:var(--dc-3); font-size:32px; font-weight:800; letter-spacing:-.03em; line-height:1.2; }
.dc-doc__meta { margin-top:var(--dc-2); font-size:13px; color:var(--dc-text-mute); }
.dc-doc__lead { margin-top:var(--dc-4); font-size:17px; line-height:1.7; color:var(--dc-text-sub); }
.dc-doc__h2 { margin-top:var(--dc-7); padding-top:var(--dc-3); border-top:1px solid var(--dc-line); font-size:21px; font-weight:700; letter-spacing:-.02em; }
.dc-doc__h3 { margin-top:var(--dc-5); font-size:16px; font-weight:700; }
.dc-doc__p { margin-top:var(--dc-3); font-size:15px; line-height:1.75; }
.dc-doc__ol, .dc-doc__ul { margin-top:var(--dc-3); display:flex; flex-direction:column; gap:var(--dc-2); }
.dc-doc__li { position:relative; padding-left:var(--dc-5); font-size:15px; line-height:1.7; }
.dc-doc__ol { counter-reset:dc-doc-step; }
.dc-doc__ol .dc-doc__li::before { counter-increment:dc-doc-step; content:counter(dc-doc-step); position:absolute; left:0; top:3px; display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:var(--dc-r-full); background:var(--dc-brand-soft); color:var(--dc-brand-text); font-size:12px; font-weight:700; }
.dc-doc__ul .dc-doc__li::before { content:""; position:absolute; left:6px; top:10px; width:6px; height:6px; border-radius:var(--dc-r-full); background:var(--dc-line-bold); }
.dc-doc__note { margin-top:var(--dc-4); padding:var(--dc-4); border:1px solid var(--dc-line); border-left:3px solid var(--dc-info); border-radius:var(--dc-r-sm); background:var(--dc-info-soft); }
.dc-doc__note-title { font-size:14px; font-weight:700; color:var(--dc-info); }
.dc-doc__note-text { margin-top:var(--dc-1); font-size:14px; line-height:1.7; }
.dc-doc__code { margin-top:var(--dc-4); padding:var(--dc-4); border:1px solid var(--dc-line); border-radius:var(--dc-r-sm); background:var(--dc-sunken); overflow-x:auto; }
.dc-doc__code-text { display:block; font-family:var(--dc-mono); font-size:13px; line-height:1.65; color:var(--dc-text); white-space:pre; }
.dc-doc__dl { margin-top:var(--dc-3); display:grid; grid-template-columns:auto minmax(0,1fr); gap:var(--dc-2) var(--dc-4); }
.dc-doc__dt { font-family:var(--dc-mono); font-size:13px; font-weight:700; color:var(--dc-brand-text); white-space:nowrap; }
.dc-doc__dd { margin:0; font-size:14px; line-height:1.65; color:var(--dc-text-sub); }
.dc-doc__foot { margin-top:var(--dc-8); padding-top:var(--dc-5); border-top:1px solid var(--dc-line); display:flex; flex-direction:column; gap:var(--dc-5); }
.dc-doc__helpful { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:var(--dc-3); padding:var(--dc-4); border:1px solid var(--dc-line); border-radius:var(--dc-r-md); background:var(--dc-surface); }
.dc-doc__helpful-q { font-size:14px; font-weight:600; }
.dc-doc__helpful-act { display:flex; gap:var(--dc-2); }
.dc-doc__vote { min-height:40px; padding:0 var(--dc-4); border:1px solid var(--dc-line-field); border-radius:var(--dc-r-sm); background:var(--dc-surface); color:var(--dc-text); font-size:14px; font-weight:600; }
.dc-doc__vote:hover { background:var(--dc-sunken); }
.dc-doc__around { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:var(--dc-3); }
.dc-doc__around-link { display:flex; flex-direction:column; gap:var(--dc-1); padding:var(--dc-4); border:1px solid var(--dc-line); border-radius:var(--dc-r-md); color:inherit; text-decoration:none; }
.dc-doc__around-link:hover { border-color:var(--dc-line-bold); background:var(--dc-sunken); }
.dc-doc__around-link--next { text-align:right; }
.dc-doc__around-dir { font-size:12px; color:var(--dc-text-mute); }
.dc-doc__around-name { font-size:15px; font-weight:600; color:var(--dc-brand); }
.dc-doc__side { position:sticky; top:76px; }
.dc-doc__toc { display:flex; flex-direction:column; gap:var(--dc-2); padding:var(--dc-4); border:1px solid var(--dc-line); border-radius:var(--dc-r-md); background:var(--dc-surface); }
.dc-doc__toc-title { font-size:12px; font-weight:700; letter-spacing:.04em; color:var(--dc-text-mute); }
.dc-doc__toc-list { display:flex; flex-direction:column; }
.dc-doc__toc-link { display:block; padding:var(--dc-2) var(--dc-3); border-left:2px solid var(--dc-line); font-size:13px; line-height:1.5; color:var(--dc-text-sub); text-decoration:none; }
.dc-doc__toc-link:hover { color:var(--dc-text); background:var(--dc-sunken); }
.dc-doc__toc-link--on { border-left-color:var(--dc-brand); color:var(--dc-brand-text); font-weight:700; background:var(--dc-brand-soft); }
.dc-doc__toc-link--sub { padding-left:var(--dc-5); font-size:12px; }
.dc-doc__edit { margin-top:var(--dc-2); padding-top:var(--dc-3); border-top:1px solid var(--dc-line); font-size:12px; color:var(--dc-text-sub); text-decoration:none; }
.dc-doc__edit:hover { color:var(--dc-brand); text-decoration:underline; }
@media (max-width:1000px) {
.dc-doc__body { grid-template-columns:1fr; gap:var(--dc-5); padding:var(--dc-5) var(--dc-4) var(--dc-7); }
.dc-doc__side { position:static; order:-1; }
.dc-doc__h1 { font-size:26px; }
.dc-doc__around { grid-template-columns:1fr; }
.dc-doc__around-link--next { text-align:left; }
}
Cara memakainya
- Batasi panjang baris sekitar 70 karakter; di layar lebar baris panjang membuat mata tersesat saat kembali
- Isi daftar isi hanya dengan h2 dan h3, tempelkan, dan tandai bagian aktif dengan aria-current
- Tutup dengan dokumen sebelum dan sesudah serta "apakah ini membantu"
Hal yang perlu diwaspadai
- Biarkan blok kode bergulir mendatar sendiri; satu baris panjang tak boleh menggeser seluruh halaman
- Di bawah 1024px pindahkan daftar isi ke atas artikel — kolom samping menghimpit teks pada lebar itu