본문으로 건너뛰기

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일괄 작업이 끝날 때
feedback.response_received누군가 피드백 양식에 답할 때
feedback.low_score_received응답이 낮은 점수 기준을 밑돌 때
feedback.response_resolved받은 편지함에서 응답이 처리됨으로 표시될 때
feedback.campaign_published피드백 배치가 공개될 때

페이로드

이벤트가 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"
    }
  }
}

피드백 이벤트

피드백 모듈은 네 개의 이벤트로 덮이며, 그것이 통합 표면 전부입니다. 모든 전송은 ID와 점수, 점수 종류를 담지만 누군가 적은 문장은 절대 담지 않습니다. 웹훅 본문은 수신 측이 저장하는 곳에 남고, 그것은 워크스페이스가 고객을 대신해 내릴 결정이 아닙니다.

{
  "id": "7c41…",
  "type": "feedback.response_received",
  "workspaceId": "2f60…",
  "occurredAt": "2026-08-05T10:20:30.456Z",
  "data": {
    "responseId": "9d22…",
    "campaignId": "3e80…",
    "placementId": "b5ae…",
    "score": 9,
    "scoreKind": "nps"
  }
}

feedback.low_score_received 는 워크스페이스에서 낮은 점수 알림이 켜져 있는 동안, 그리고 거기서 정한 기준 아래에서만 발생합니다. feedback.response_received 는 어떤 경우에도 모든 제출에 대해 발생하므로 전부 필요하면 이 이벤트를 구독하세요.

응답을 가져오는 API 키 엔드포인트는 없습니다. 모듈은 키가 닿지 않는 세션 인증 경로로 읽으므로, 응답 자체는 받은 편지함에서 CSV로 나갑니다. 가져오기 경로가 있다고 가정하지 말고 문의해 주세요.

전송 검증하기

각 요청에는 타임스탬프와, 타임스탬프와 본문 양쪽에 대해 계산된 서명이 함께 담깁니다.

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=… 은 안정적인 커서로 스캔 기록을 앞으로 훑어 나갑니다.