웹훅
Bona Fide는 구독한 봉투 이벤트마다 엔드포인트로 HTTPS POST를 보냅니다. 전송은
Standard Webhooks 사양을 따르므로 이 사양의 어떤 라이브러리로도 검증할 수 있습니다.
엔드포인트 등록
섹션 제목: “엔드포인트 등록”설정 › 웹훅에서 등록하거나, org:manage
스코프가 있는 API 키로 등록합니다.
curl https://api.bonafidesign.com/v1/webhook-endpoints \ -H "Authorization: Bearer $BONA_FIDE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/hooks/bona-fide", "description": "Production", "events": ["envelope.completed", "envelope.sealed", "envelope.declined"] }'응답에는 secret(whsec_…)이 포함되며, 이때 한 번만 제공됩니다. events 목록을 비우면 모든 이벤트를 구독합니다.
URL은 공개된 https://여야 합니다. 엔드포인트는 하나의 모드에 속합니다. 테스트 키로 만든 엔드포인트는
테스트 모드 봉투의 이벤트만, 라이브 엔드포인트는 라이브 이벤트만 받습니다.
PATCH /v1/webhook-endpoints/{id}는 URL, 이벤트, 설명을 변경하고 엔드포인트를 일시 중지하거나 재개합니다
(enabled). POST /v1/webhook-endpoints/{id}/test는 webhook.test 핑을 보냅니다.
이벤트
섹션 제목: “이벤트”| 유형 | 발생 시점 |
|---|---|
envelope.created |
봉투가 생성됨 |
envelope.ready |
문서가 렌더링되어 봉투가 수신자를 맞을 준비가 됨 |
recipient.sent |
수신자의 차례가 됨(이메일 전달의 경우 해당 수신자에게 링크가 전송됨) |
recipient.viewed |
수신자가 서명 페이지를 처음 엶 |
recipient.signed |
수신자가 제출함(또는 자동으로 연서명됨) |
recipient.declined |
수신자가 거절함 |
envelope.completed |
모든 수신자가 완료함. data.values에 변수와 입력된 모든 값이 포함됨 |
envelope.sealed |
봉인된 PDF를 다운로드할 수 있음 |
envelope.declined |
수신자가 거절하여 봉투가 종료됨 |
envelope.voided |
봉투를 무효화함 |
envelope.expired |
완료되기 전에 expiresAt이 지남 |
signing_link.used |
방문자가 상시 서명 링크에서 봉투를 시작함 |
acceptance.created는 스키마에 예약되어 있지만 아직 발생하지 않습니다.
페이로드
섹션 제목: “페이로드”{ "id": "evt_9sKq2Lm4Tz", "type": "envelope.completed", "createdAt": "2026-10-13T14:09:52.004Z", "testMode": false, "data": { "envelope": { "id": "env_8Gm2kQ4rT1", "status": "completed", "seal": "pending", "recipients": [{ "id": "rcp_5Jw0pLs9Xe", "role": "client", "status": "completed", "…": "…" }], "externalId": "user_8841", "metadata": {}, "…": "…" }, "values": { "variables": { "client": { "name": "Acme Robotics, Inc." }, "effective_date": "2026-10-13" }, "recipients": { "client": { "client_title": "CEO", "start_date": "2026-11-01" } } } }}data.envelope는 이벤트 발생 시점의 전체 봉투입니다. data.values는
envelope.completed에만 있으며, 보낸 변수와 각 역할의 필드 값(필드 ID별)이 담겨 있습니다.
헤더와 서명
섹션 제목: “헤더와 서명”각 전송에는 다음 헤더가 포함됩니다.
| 헤더 | 값 |
|---|---|
webhook-id |
이벤트 ID(evt_…). 재시도할 때도 같습니다. 중복 제거에 사용하세요 |
webhook-timestamp |
이 시도에 서명한 시각(Unix 초) |
webhook-signature |
v1,<base64 HMAC-SHA256>. 둘 이상의 시크릿으로 서명하면 공백으로 구분됩니다 |
user-agent |
BonaFide-Webhooks/1 |
서명은 {webhook-id}.{webhook-timestamp}.{raw body}에 대한 HMAC-SHA256이며, 키는 시크릿에서 whsec_ 뒤 부분을
base64 디코딩한 값입니다. (JSON 파싱 전의) 원시 본문을 검증하고, 상수 시간으로 비교하며,
서버 시계와 5분 넘게 차이 나는 타임스탬프는 거부하세요.
import express from 'express';import { Webhook } from 'standardwebhooks'; // npm i standardwebhooks
const wh = new Webhook(process.env.BONA_FIDE_WEBHOOK_SECRET!); // whsec_…const app = express();
app.post('/hooks/bona-fide', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = wh.verify(req.body, req.headers as Record<string, string>); // throws if forged or too old } catch { return res.sendStatus(400); } queue.add(event); // do the work asynchronously and answer quickly res.sendStatus(204);});# pip install standardwebhooks flaskimport osfrom flask import Flask, requestfrom standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["BONA_FIDE_WEBHOOK_SECRET"]) # whsec_…app = Flask(__name__)
@app.post("/hooks/bona-fide")def bona_fide(): try: event = wh.verify(request.get_data(), dict(request.headers)) except Exception: return "", 400 enqueue(event) return "", 204import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, headers: Record<string, string>, rawBody: string) { const id = headers['webhook-id']; const ts = Number(headers['webhook-timestamp']); if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) throw new Error('stale or missing headers'); const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest(); const ok = (headers['webhook-signature'] ?? '').split(' ').some((part) => { const [version, sig] = part.split(','); const given = Buffer.from(sig ?? '', 'base64'); return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected); }); if (!ok) throw new Error('bad signature'); return JSON.parse(rawBody);}재시도
섹션 제목: “재시도”10초 안에 2xx가 반환되면 전송에 성공한 것입니다. 리디렉션은 따라가지 않습니다. 그 밖의 경우에는 백오프를 적용해 재시도하며,
5초, 5분, 30분, 2시간, 5시간, 10시간, 14시간, 20시간, 24시간 후에 다시 시도하고,
이벤트 발생 후 최대 3일 동안 계속합니다. 그 후에는 전송이 실패로 표시됩니다.
- 빠르게 응답하고 처리는 비동기로 하세요. 중복 제거에는
webhook-id를 사용하세요. - 전송은 순서가 바뀌어 도착할 수 있습니다. 순서를 가정하지 말고
data.envelope.status를 확인하세요. GET /v1/webhook-endpoints/{id}/deliveries는 최근 시도를 상태 코드, 오류, 소요 시간과 함께 나열하며, 각 엔드포인트에는 24시간 성공률(health)이 표시됩니다.POST /v1/events/{id}/redeliver는 예를 들어 장애 이후에 이벤트를 엔드포인트로 다시 보냅니다.
시크릿 교체
섹션 제목: “시크릿 교체”POST /v1/webhook-endpoints/{id}/secret/rotate는 새 시크릿을 즉시 반환합니다. 이후 24시간 동안 모든
전송은 새 시크릿과 이전 시크릿 모두로 서명되므로(webhook-signature에 항목 두 개), 이벤트를 놓치지 않고
새 시크릿을 배포할 수 있습니다.