Gå til indhold

Mosaqo-webhooks

Vi sender en POST med signeret JSON til dit endpoint for hver hændelse, du abonnerer på, og prøver igen med voksende mellemrum, indtil du tager imod den.

Abonnér

Tilføj enten et endpoint under Bulk & API, eller registrér et fra din egen kode med en webhooks:write-nøgle — sådan tænder og slukker en automatiseringsplatform en trigger:

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"]
  }'

Svaret indeholder signeringens secret. Den vises kun én gang — gem den, eller udskift abonnementet senere.

Hændelser

HændelseUdløses når
qr.createdEn kode oprettes, i appen eller via API’et
qr.publishedEn kode går live, og dens omdirigering begynder at svare
qr.updatedIndhold, design, navn eller status ændres
qr.archivedEn kode arkiveres og tages af omdirigeringsværten
qr.deletedEn kode slettes permanent
redirect.rule_changedEn destination eller en målretningsregel ændres
scan.aggregate_readyEn scanningsaggregering er klar
export.readyEn asynkron eksport er færdig
approval.changedEn godkendelse gives eller afvises
bulk.completedEn masseopgave afsluttes

Payload

Når en hændelse handler om en QR-kode, bærer data.qr det samme resumé, som GET /qr/{id} returnerer — en trigger behøver altså ikke spørge tilbage efter den post, den lige har fået besked om.

{
  "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"
    }
  }
}

Verificér en levering

Hvert kald bærer et tidsstempel og en signatur over både tidsstemplet og kroppen:

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

At afvise alt, hvis tidsstempel ligger langt fra dit eget ur, er netop dét, der gør en opsnappet levering umulig at gentage — spring ikke det tjek over. Sammenlign signaturer i konstant tid, og brug kaldets krop: at serialisere den parsede JSON igen ændrer bytes, og så passer signaturen ikke længere.

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"])

Gentagelser og fejl

Svar med et hvilket som helst 2xx, så snart du har gemt hændelsen; arbejdet gør du bagefter. Vi prøver op til seks gange med eksponentielt voksende mellemrum, så en langsom handler bliver til dobbelte leveringer — behandl X-Mosaqo-Delivery som en idempotensnøgle hos dig.

Endpoints skal være HTTPS på en offentlig vært. Lokale og private adresser afvises allerede, når abonnementet oprettes.

Udfaset signatur

X-Mosaqo-Signature-V0 bærer en ældre signatur beregnet alene på kroppen. Den kan ikke udtrykke beskyttelse mod gentagelse, følger med én udgivelse, så eksisterende modtagere kan skifte, og må ikke bruges til noget nyt.

Scanningsdata sendes ikke ud

Der findes bevidst ingen webhook pr. scanning. Mosaqo sender ikke analyse på scanningsniveau til tredjeparts-endpoints, og ingen hændelse i tabellen ovenfor bærer den.

Scanningshistorikken er kun tilgængelig ved, at du henter den selv med din egen nøgle — GET /scans — og selv da styres den af arbejdsområdets privatlivsindstillinger for analyse: dimensioner, du har slået fra, mangler i hver række, botfilteret gælder, og den pseudonyme besøgshash bag “unikke scanninger” udleveres aldrig. Svaret viser allowedDimensions, så du ved, hvad du får.

scan.aggregate_ready fortæller, at en aggregering er klar; den indeholder ikke de underliggende scanninger. Har din integration virkelig brug for enkelte scanninger i realtid, kan det lade sig gøre efter aftale — tal med os i stedet for at gå ud fra, at det findes.

Hvis du ikke kan modtage webhooks

Så spørg selv. GET /qr?updatedSince=… giver ændrede koder, og GET /scans?since=… går fremad gennem scanningshistorikken med en stabil markør.