跳到主要内容

Mosaqo Webhooks

你订阅的每个事件,我们都会以 POST 把带签名的 JSON 发到你的端点,并按退避策略重试,直到你接收为止。

订阅

既可以在 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一个批量任务结束

载荷

当事件与某个二维码有关时,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 带的是仅覆盖请求体的旧签名。它无法表达重放保护,只会再保留一个版本供现有接收端迁移,新的集成一律不要使用。

扫描数据不会推送

我们刻意不提供单次扫描级别的 webhook。Mosaqo 不会把扫描级别的分析数据发送到第三方端点,上表中的任何事件也都不携带这类数据。

扫描历史只能由你自己用密钥去取——GET /scans——而且即便如此,它仍受工作区分析隐私设置的约束:你关闭的维度会从每一行里省略,机器人过滤照常生效,“独立扫描”背后的匿名访客哈希永远不会暴露。响应中会列出 allowedDimensions,让你清楚拿到的是什么。

scan.aggregate_ready 只是告诉你汇总数据已经就绪,它不包含底层的扫描记录。如果你的集成确实需要实时拿到单次扫描,这可以按需开通——请直接联系我们,不要默认它已经存在。

如果你无法接收 webhook

那就改用轮询。GET /qr?updatedSince=… 返回有变化的码,GET /scans?since=… 则用稳定的游标向前遍历扫描历史。