Mosaqo webhooks
Ми надсилаємо POST із підписаним JSON на ваш endpoint для кожної події, на яку ви підписані, і повторюємо з наростаючою затримкою, доки ви її не приймете.
Підписка
Або додайте endpoint у розділі Пакетна робота та 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 | Точка фідбеку вийшла в ефір |
Payload
Коли подія стосується 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": "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 за ключ ідемпотентності на своєму боці.
Endpoint має бути HTTPS на публічному хості. Локальні та приватні адреси відхиляються ще під час створення підписки.
Застарілий підпис
X-Mosaqo-Signature-V0 несе давніший підпис, обчислений лише над тілом. Він не здатен виразити захист від повтору, їде ще один реліз, щоб наявні отримувачі мігрували, і не має використовуватись у нічому новому.
Дані сканувань не виштовхуються
Вебхука на окреме сканування навмисно немає. Mosaqo не надсилає аналітику рівня сканувань на сторонні endpoint-и, і жодна подія з таблиці вище її не несе.
Історія сканувань доступна лише тоді, коли ви самі її забираєте власним ключем — GET /scans, — і навіть тоді нею керують налаштування приватності аналітики вашого простору: вимкнені виміри не потрапляють у жоден рядок, фільтр ботів діє, а псевдонімний хеш відвідувача, на якому будуються «унікальні сканування», не віддається ніколи. У відповіді є allowedDimensions, щоб ви знали, що саме отримуєте.
scan.aggregate_ready повідомляє, що зведення готове; самих сканувань воно не містить. Якщо вашій інтеграції справді потрібні окремі сканування в реальному часі — це доступно за окремим запитом, тож напишіть нам, а не припускайте, що воно вже є.
Якщо ви не можете приймати webhooks
Опитуйте. GET /qr?updatedSince=… дає змінені коди, а GET /scans?since=… проходить історію сканувань уперед зі стабільним курсором.