Mosaqo 웹훅
구독한 이벤트가 발생할 때마다 서명된 JSON 본문을 여러분의 엔드포인트로 POST하고, 수신될 때까지 백오프를 두고 재전송합니다.
구독하기
Bulk & API에서 엔드포인트를 추가하거나, webhooks:write 키로 직접 코드에서 등록하세요 — 자동화 플랫폼이 트리거를 켜고 끄는 방식이 바로 이것입니다.
curl -X POST https://api.mosaqo.app/v1/public-api/webhooks \
-H "Authorization: Bearer $MOSAQO_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/mosaqo",
"eventTypes": ["qr.published", "qr.updated"]
}'응답에는 서명용 secret 이 들어 있습니다. 한 번만 표시되므로 저장해 두거나, 나중에 구독을 다시 만드세요.
이벤트
| 이벤트 | 발생 시점 |
|---|---|
qr.created | 앱이나 API를 통해 코드가 생성될 때 |
qr.published | 코드가 게시되어 리다이렉트가 동작하기 시작할 때 |
qr.updated | 콘텐츠, 디자인, 이름 또는 상태가 바뀔 때 |
qr.archived | 코드가 보관되어 리다이렉트 호스트에서 내려갈 때 |
qr.deleted | 코드가 영구히 삭제될 때 |
redirect.rule_changed | 연결 대상이나 타기팅 규칙이 바뀔 때 |
scan.aggregate_ready | 스캔 집계가 완료되었을 때 |
export.ready | 비동기 내보내기가 끝났을 때 |
approval.changed | 검토가 승인되거나 반려될 때 |
bulk.completed | 일괄 작업이 끝날 때 |
페이로드
이벤트가 QR 코드에 관한 것이면 data.qr 에는 GET /qr/{id} 가 반환하는 것과 동일한 요약이 담깁니다. 트리거가 방금 통보받은 레코드를 다시 조회할 필요가 없습니다.
{
"id": "0b0f…",
"type": "qr.published",
"workspaceId": "2f60…",
"occurredAt": "2026-08-05T10:20:30.456Z",
"data": {
"qrId": "e7b7…",
"source": "public_api",
"qr": {
"id": "e7b7…",
"name": "Autumn campaign",
"slug": "autumn-campaign-1a2b3c4d",
"mode": "dynamic",
"contentType": "url",
"status": "published",
"approvalState": "not_required",
"folderId": null,
"updatedAt": "2026-08-05T10:20:30.401Z",
"publicUrl": "https://mosaqo.link/aB3dEf"
}
}
}전송 검증하기
각 요청에는 타임스탬프와, 타임스탬프와 본문 양쪽에 대해 계산된 서명이 함께 담깁니다.
X-Mosaqo-Timestamp: 1785942718
X-Mosaqo-Signature: t=1785942718,v1=<hex HMAC-SHA256 of "{timestamp}.{raw body}">
X-Mosaqo-Delivery: 0b0f…
X-Mosaqo-Event: qr.published타임스탬프가 내 시계와 크게 어긋난 요청을 거부하는 것이 가로챈 전송을 재생할 수 없게 만드는 핵심이므로, 이 검사를 건너뛰지 마세요. 서명은 상수 시간으로 비교하고, 원본 요청 본문을 사용하세요 — 파싱된 JSON을 다시 직렬화하면 바이트가 달라져 서명이 맞지 않습니다.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyMosaqoWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((piece) => piece.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
return expected.length === received.length && timingSafeEqual(expected, received);
}PHP
<?php
function verify_mosaqo_webhook(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
[$key, $value] = array_map('trim', explode('=', $piece, 2));
$parts[$key] = $value;
}
if (!isset($parts['t'], $parts['v1'])) return false;
if (abs(time() - (int) $parts['t']) > $tolerance) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}Python
import hmac, time
from hashlib import sha256
def verify_mosaqo_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(piece.strip().split("=", 1) for piece in header.split(","))
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > tolerance:
return False
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])재전송과 실패
이벤트를 저장하자마자 아무 2xx로 응답하고, 실제 처리는 그 뒤에 하세요. 지수 백오프로 최대 여섯 번까지 재전송하므로 핸들러가 느리면 중복 전송으로 이어집니다. X-Mosaqo-Delivery 를 여러분 쪽의 멱등성 키로 다루세요.
엔드포인트는 공개 호스트의 HTTPS여야 합니다. 로컬 주소와 사설 주소는 구독을 만들 때 거부됩니다.
더 이상 사용하지 않는 서명
X-Mosaqo-Signature-V0 는 본문만으로 계산한 이전 서명입니다. 재생 공격 방지를 표현할 수 없으며, 기존 수신 측이 이전할 수 있도록 한 릴리스 동안만 함께 제공됩니다. 새로운 용도에는 사용해서는 안 됩니다.
스캔 데이터는 푸시되지 않습니다
스캔 단위 웹훅은 의도적으로 없습니다. Mosaqo는 스캔 수준의 분석 데이터를 외부 엔드포인트로 보내지 않으며, 위 표의 어떤 이벤트에도 담기지 않습니다.
스캔 기록은 자신의 키로 직접 GET /scans 를 호출해 가져오는 방법으로만 얻을 수 있고, 그때에도 워크스페이스의 분석 개인정보 설정이 적용됩니다. 꺼 둔 항목은 모든 행에서 빠지고, 봇 필터가 적용되며, “고유 스캔” 뒤에 있는 가명 방문자 해시는 절대 노출되지 않습니다. 응답에는 allowedDimensions 가 포함되어 무엇을 받게 되는지 알 수 있습니다.
scan.aggregate_ready 는 집계가 준비되었음을 알릴 뿐, 그 바탕이 된 스캔은 담고 있지 않습니다. 통합에 개별 스캔의 실시간 전달이 정말로 필요하다면 요청 시 지원이 가능합니다. 이미 있다고 가정하지 말고 저희에게 문의하세요.
웹훅을 받을 수 없다면
대신 폴링하세요. GET /qr?updatedSince=… 은 변경된 코드를 돌려주고, GET /scans?since=… 은 안정적인 커서로 스캔 기록을 앞으로 훑어 나갑니다.