Webhooks Mosaqo
Nous envoyons un POST au corps JSON signé vers votre endpoint pour chaque événement auquel vous êtes abonné, en réessayant avec un délai croissant jusqu’à ce que vous l’acceptiez.
S’abonner
Ajoutez un endpoint dans Bulk & API, ou enregistrez-en un depuis votre propre code avec une clé webhooks:write — c’est ainsi qu’une plateforme d’automatisation active et désactive un déclencheur :
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"]
}'La réponse contient le secret de signature. Il n’est affiché qu’une fois — conservez-le, ou remplacez l’abonnement plus tard.
Événements
| Événement | Se déclenche quand |
|---|---|
qr.created | Un code est créé, dans l’application ou via l’API |
qr.published | Un code passe en ligne et sa redirection commence à répondre |
qr.updated | Le contenu, le design, le nom ou le statut change |
qr.archived | Un code est archivé et retiré de l’hôte de redirection |
qr.deleted | Un code est supprimé définitivement |
redirect.rule_changed | Une destination ou une règle de ciblage change |
scan.aggregate_ready | Une agrégation de scans est prête |
export.ready | Un export asynchrone est terminé |
approval.changed | Une relecture est approuvée ou rejetée |
bulk.completed | Une tâche en lot se termine |
Payload
Quand un événement concerne un QR code, data.qr porte le même résumé que celui renvoyé par GET /qr/{id} — un déclencheur n’a donc pas à redemander la fiche dont on vient de lui parler.
{
"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"
}
}
}Vérifier une livraison
Chaque requête porte un horodatage et une signature couvrant à la fois l’horodatage et le corps :
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.publishedRejeter tout ce dont l’horodatage s’éloigne de votre propre horloge est précisément ce qui rend une livraison interceptée non rejouable : ne sautez pas cette vérification. Comparez les signatures en temps constant et utilisez le corps brut de la requête — resérialiser le JSON analysé change les octets, et la signature ne correspondra plus.
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"])Nouvelles tentatives et échecs
Répondez par n’importe quel 2xx dès que vous avez stocké l’événement, et faites le travail ensuite. Nous réessayons jusqu’à six fois avec un délai exponentiel : un traitement lent se traduit donc par des livraisons en double — traitez X-Mosaqo-Delivery comme une clé d’idempotence de votre côté.
Les endpoints doivent être en HTTPS sur un hôte public. Les adresses locales et privées sont refusées dès la création de l’abonnement.
Signature dépréciée
X-Mosaqo-Signature-V0 porte une signature plus ancienne calculée sur le seul corps. Elle ne peut exprimer aucune protection contre le rejeu, reste présente une version le temps que les récepteurs existants migrent, et ne doit servir à rien de nouveau.
Les données de scan ne sont pas poussées
Il n’existe délibérément aucun webhook par scan. Mosaqo n’envoie pas de statistiques au niveau du scan vers des endpoints tiers, et aucun événement du tableau ci-dessus n’en transporte.
L’historique des scans n’est accessible qu’en le récupérant vous-même avec votre propre clé — GET /scans — et il reste alors régi par les réglages de confidentialité analytique de votre espace : les dimensions que vous avez désactivées sont absentes de chaque ligne, le filtre anti-bots s’applique, et le hachage pseudonyme du visiteur derrière les « scans uniques » n’est jamais exposé. La réponse liste allowedDimensions pour que vous sachiez ce que vous obtenez.
scan.aggregate_ready signale qu’une agrégation est prête ; il ne contient pas les scans sous-jacents. Si votre intégration a réellement besoin des scans individuels en temps réel, c’est possible sur demande — parlez-nous-en plutôt que de le supposer acquis.
Si vous ne pouvez pas recevoir de webhooks
Interrogez l’API. GET /qr?updatedSince=… donne les codes modifiés, et GET /scans?since=… parcourt l’historique des scans avec un curseur stable.