Skip to content

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

EventFires when
qr.createdA code is created, in the app or through the API
qr.publishedA code goes live and its redirect starts resolving
qr.updatedContent, design, name or status changes
qr.archivedA code is archived and taken off the redirect host
qr.deletedA code is permanently deleted
redirect.rule_changedA destination or targeting rule changes
scan.aggregate_readyA scan aggregate has been rolled up
export.readyAn asynchronous export has finished
approval.changedA review is approved or rejected
bulk.completedA 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.published

Rejecting 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.