本文へスキップ

Mosaqo Webhook

購読しているイベントが発生するたびに、署名付きの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を返してください。処理はその後で構いません。指数バックオフで最大6回まで再送するため、ハンドラが遅いと配信が重複します。X-Mosaqo-Delivery を自分側の冪等性キーとして扱ってください。

エンドポイントは公開ホスト上のHTTPSである必要があります。ローカルアドレスやプライベートアドレスは、サブスクリプション作成時に拒否されます。

非推奨の署名

X-Mosaqo-Signature-V0 は、ボディのみを対象に計算された古い署名です。リプレイ保護を表現できず、既存の受信側が移行できるよう1リリースだけ残されています。新規の用途に使ってはいけません。

スキャンデータはプッシュされません

スキャン単位のWebhookは意図的に存在しません。Mosaqoはスキャンレベルのアナリティクスを外部エンドポイントに送信せず、上の表のどのイベントにも含まれません。

スキャン履歴は、自分のキーで GET /scans を呼んで取得する方法でのみ得られます。しかもその内容は、ワークスペースのアナリティクスのプライバシー設定に従います。オフにしたディメンションはどの行からも除かれ、ボットフィルタが適用され、「ユニークスキャン」の裏側にある仮名化された訪問者ハッシュが公開されることはありません。レスポンスには allowedDimensions が含まれるので、何が得られるかが分かります。

scan.aggregate_ready は集計が完了したことを知らせるだけで、元となったスキャンは含みません。個々のスキャンをリアルタイムで受け取る必要が本当にある場合は、リクエストに応じて対応します。存在すると想定せず、ご相談ください。

Webhookを受信できない場合

代わりにポーリングしてください。GET /qr?updatedSince=… で変更されたコードが得られ、GET /scans?since=… は安定したカーソルでスキャン履歴を辿ります。