Files
eapim-portal/src/main/resources/templates/views/apps/service/webhook-dev-guide.html
T

529 lines
33 KiB
HTML

<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
<body>
<th:block layout:fragment="contentFragment">
<section class="oauth2-2legged">
<!-- Section 1: Hero -->
<header class="oauth2-2legged__hero">
<div class="oauth2-2legged__hero-inner">
<div class="oauth2-2legged__hero-body">
<span class="oauth2-2legged__hero-eyebrow">
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
단계별로 설명합니다.</p>
<!-- <div class="oauth2-2legged__hero-chips">
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
<span class="oauth2-2legged__chip">Raw Body</span>
</div> -->
</div>
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="Signed Webhook Delivery">
<defs>
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker>
</defs>
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF" />
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Webhook</text>
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Your</text>
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Endpoint</text>
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2"
marker-end="url(#whsig-hero-arrow)" />
<g transform="translate(58,28)">
<rect width="164" height="34" rx="6" fill="#1A1A2E" />
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9"
fill="#00D4FF">X-Webhook-Signature:</text>
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9"
fill="#FFD93D">sha256=9f86d0..</text>
</g>
<g transform="translate(133,104)">
<circle r="13" fill="#0049b4" />
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF"
stroke-width="1.5" />
</g>
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600"
fill="#64748B">Signed Webhook Delivery</text>
</svg>
</div>
</div>
</header>
<div class="container service-main">
<!-- Sidebar -->
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
<div class="service-content">
<!-- Section 2: 개요 -->
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
<div class="oauth2-2legged__prereq-grid">
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">1</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
<p>[마이페이지 &gt; Webhook 관리]에서 발급된</p>
<p>Secret Key를 서버에 안전하게 보관.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">2</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
<p>바이트 원문 그대로 서명 계산에 사용.</p>
</div>
</article>
<article class="oauth2-2legged__prereq-card">
<span class="oauth2-2legged__prereq-num">3</span>
<div class="oauth2-2legged__prereq-body">
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
<p>헤더 서명과 상수 시간으로 비교.</p>
</div>
</article>
</div>
</section>
<!-- Section 3: 전체 서명·검증 시퀀스 -->
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
<div class="oauth2-2legged__sequence-diagram">
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img"
aria-label="Webhook Signature Verification Sequence">
<defs>
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="8" markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
</marker>
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
markerHeight="8" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B" />
</marker>
</defs>
<g>
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
stroke="#0049b4" />
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Webhook Sender</text>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<g>
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
stroke="#0049b4" />
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">Your Endpoint</text>
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700"
fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=&lt;hex&gt;</text>
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2"
marker-end="url(#whsig-arrow-primary)" />
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">④ 동일 secret으로 재계산</text>
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700"
fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2"
marker-end="url(#whsig-arrow-gray)" />
</svg>
</div>
</section>
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">POST</span>
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>HEADER</th>
<th>설명</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>X-Webhook-Signature</code></td>
<td><code>sha256=&lt;hex&gt;</code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
</tr>
<tr>
<td><code>X-Webhook-Event</code></td>
<td>이벤트 코드 (예: CONTROL_START)</td>
</tr>
<tr>
<td><code>X-Webhook-Timestamp</code></td>
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
</tr>
<tr>
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
</p>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
<pre class="oauth2-2legged__code-block">{
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
}
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
</div>
</div>
</section>
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
<span class="oauth2-2legged__eyebrow">STEP 2</span>
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Verification Steps</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
digest = HMAC_SHA256(secret, rawBody) <span class="o2leg-g"># bytes</span>
expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
valid = constantTimeEquals(received, expected)</pre>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
<ul class="oauth2-2legged__tips">
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
</ul>
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
<ul class="oauth2-2legged__tips">
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
</ul>
</div>
</div>
</section>
<!-- Section 6: STEP 3 — 언어별 예제 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
<span class="oauth2-2legged__eyebrow">STEP 3</span>
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
<span class="o2leg-p">const</span> expected = <span class="o2leg-c">'sha256='</span> +
crypto.<span class="o2leg-y">createHmac</span>(<span class="o2leg-c">'sha256'</span>, secret)
.<span class="o2leg-y">update</span>(rawBody)
.<span class="o2leg-y">digest</span>(<span class="o2leg-c">'hex'</span>);
<span class="o2leg-p">const</span> a = Buffer.<span class="o2leg-y">from</span>(header);
<span class="o2leg-p">const</span> b = Buffer.<span class="o2leg-y">from</span>(expected);
<span class="o2leg-p">return</span> a.length === b.length &amp;&amp;
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
}</pre>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
digest = hmac.<span class="o2leg-y">new</span>(
secret.<span class="o2leg-y">encode</span>(<span class="o2leg-c">'utf-8'</span>),
raw_body,
hashlib.sha256
).<span class="o2leg-y">hexdigest</span>()
expected = <span class="o2leg-c">'sha256='</span> + digest
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
</div>
</div>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag">Java</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
<span class="o2leg-p">import</span> java.security.MessageDigest;
<span class="o2leg-cm">// rawBody: 파싱 전 원문 문자열</span>
<span class="o2leg-p">boolean</span> <span class="o2leg-y">verify</span>(String rawBody, String header, String secret) <span class="o2leg-p">throws</span> Exception {
Mac mac = Mac.<span class="o2leg-y">getInstance</span>(<span class="o2leg-c">"HmacSHA256"</span>);
mac.<span class="o2leg-y">init</span>(<span class="o2leg-p">new</span> SecretKeySpec(secret.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8), <span class="o2leg-c">"HmacSHA256"</span>));
<span class="o2leg-p">byte</span>[] hash = mac.<span class="o2leg-y">doFinal</span>(rawBody.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
StringBuilder hex = <span class="o2leg-p">new</span> StringBuilder();
<span class="o2leg-p">for</span> (<span class="o2leg-p">byte</span> b : hash) hex.<span class="o2leg-y">append</span>(String.<span class="o2leg-y">format</span>(<span class="o2leg-c">"%02x"</span>, b));
String expected = <span class="o2leg-c">"sha256="</span> + hex;
<span class="o2leg-p">return</span> MessageDigest.<span class="o2leg-y">isEqual</span>(
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
}</pre>
</div>
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>CODE</th>
<th>의미</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CONTROL_START</code></td>
<td>점검 시작</td>
</tr>
<tr>
<td><code>CONTROL_END</code></td>
<td>점검 종료</td>
</tr>
<tr>
<td><code>DELAY_START</code></td>
<td>지연 시작</td>
</tr>
<tr>
<td><code>DELAY_END</code></td>
<td>지연 종료</td>
</tr>
<tr>
<td><code>ERROR_START</code></td>
<td>장애 시작</td>
</tr>
<tr>
<td><code>ERROR_END</code></td>
<td>장애 종료</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
<table class="oauth2-2legged__table">
<thead>
<tr>
<th>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
</tr>
</thead>
<tbody>
<tr>
<td><span
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span>
</td>
<td>검증 성공 + 정상 접수</td>
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
</tr>
<tr>
<td><code>400</code></td>
<td>필수 헤더 누락</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>401</code></td>
<td>서명 불일치 / timestamp 만료</td>
<td>실패 기록 + 재시도</td>
</tr>
<tr>
<td><code>5xx</code> · 타임아웃</td>
<td>수신 서버 일시 장애</td>
<td>실패 기록 + 재시도</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 2xx 이외 응답과 네트워크 오류는 <strong>일정 간격을 두고
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
<ul class="oauth2-2legged__tips">
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
</ul>
</div>
<div class="oauth2-2legged__code-panel">
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON
(권장)</span>
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
HTTP/1.1 <span class="o2leg-g">200 OK</span>
Content-Type: application/json
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"OK"</span>,
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>
}
<span class="o2leg-cm"># 서명 검증 실패</span>
HTTP/1.1 <span class="o2leg-g">401 Unauthorized</span>
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"서명 검증에 실패하였습니다."</span>
}
<span class="o2leg-cm"># 필수 헤더 누락</span>
HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
{
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
}</pre>
</div>
</div>
</section>
<!-- Section 7: 검증 실패 대표 원인 -->
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
<div class="oauth2-2legged__panel">
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
<thead>
<tr>
<th>원인</th>
<th>증상</th>
<th>해결 가이드</th>
</tr>
</thead>
<tbody>
<tr>
<td>본문 재직렬화</td>
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
<td>파싱 전 raw body(bytes)로 계산</td>
</tr>
<tr>
<td>hex 대소문자</td>
<td>대문자 hex로 비교해 불일치</td>
<td>소문자 hex 사용</td>
</tr>
<tr>
<td>접두어 처리</td>
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
<td>양쪽 모두 접두 포함 후 비교</td>
</tr>
<tr>
<td>인코딩</td>
<td>Secret/본문을 UTF-8 외로 인코딩</td>
<td>키·메시지 모두 UTF-8 bytes</td>
</tr>
<tr>
<td>Secret 불일치</td>
<td>재발급 후 이전 Secret 사용</td>
<td>최신 Secret으로 교체</td>
</tr>
<tr>
<td>재전송</td>
<td>동일 이벤트 중복 수신</td>
<td>timestamp 검사 + eventId 멱등 처리</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Section 8: CTA -->
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
</a>
</div>
</div>
</section>
</th:block>
</body>
<th:block layout:fragment="contentScript">
</th:block>
</html>