Mosaqo webhooks
We POST a signed JSON body to your endpoint for every event you subscribe to, and retry with backoff until you accept it.
Subscribing
Either add an endpoint in Bulk & API, or register one from your own code with a webhooks:write key — which is how an automation platform turns a trigger on and off:
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"]
}'The response contains the signing secret. It is shown once — store it, or replace the subscription later.
Events
| Event | Fires when |
|---|---|
qr.created | A code is created, in the app or through the API |
qr.published | A code goes live and its redirect starts resolving |
qr.updated | Content, design, name or status changes |
qr.archived | A code is archived and taken off the redirect host |
qr.deleted | A code is permanently deleted |
redirect.rule_changed | A destination or targeting rule changes |
scan.aggregate_ready | A scan aggregate has been rolled up |
export.ready | An asynchronous export has finished |
approval.changed | A review is approved or rejected |
bulk.completed | A bulk job finishes |
Payload
When an event concerns a QR code, data.qr carries the same summary GET /qr/{id} returns — so a trigger does not have to call back for the record it was just told about.
{
"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"
}
}
}Verifying a delivery
Each request carries a timestamp and a signature over both the timestamp and the body:
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.publishedRejecting anything whose timestamp is far from your own clock is what makes a captured delivery unreplayable, so do not skip that check. Compare signatures in constant time, and use the raw request body — re-serialising the parsed JSON changes the bytes and the signature will not match.
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"])Retries and failures
Answer with any 2xx as soon as you have stored the event; do the work afterwards. We retry up to six times with exponential backoff, so a slow handler turns into duplicate deliveries — treat X-Mosaqo-Delivery as an idempotency key on your side.
Endpoints must be HTTPS on a public host. Local and private addresses are refused when the subscription is created.
Deprecated signature
X-Mosaqo-Signature-V0 carries an older signature computed over the body alone. It cannot express replay protection, ships for one release so existing receivers can migrate, and must not be used for anything new.
Scan data is not pushed
There is deliberately no per-scan webhook. Mosaqo does not send scan-level analytics to third-party endpoints, and no event in the table above carries one.
Scan history is available only by fetching it yourself with your own key — GET /scans — and even then it is governed by your workspace's analytics privacy settings: dimensions you have switched off are omitted from every row, the bot filter applies, and the pseudonymous visitor hash behind “unique scans” is never exposed. The response lists allowedDimensions so you know what you are getting.
scan.aggregate_ready tells you a rollup is ready; it does not contain the underlying scans. If your integration genuinely needs individual scans delivered in real time, that is available on request — talk to us about it rather than assuming it exists.
If you cannot receive webhooks
Poll instead. GET /qr?updatedSince=… gives changed codes, and GET /scans?since=… walks scan history forward with a stable cursor.