Hoppa till innehåll

Mosaqo-webhooks

Vi skickar en POST med signerad JSON till din endpoint för varje händelse du prenumererar på, och försöker om med växande mellanrum tills du tar emot den.

Prenumerera

Lägg antingen till en endpoint under Bulk & API, eller registrera en från din egen kod med en webhooks:write-nyckel — det är så en automationsplattform slår på och av 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 innehåller signeringens secret. Den visas bara en gång — spara den, eller byt ut prenumerationen senare.

Händelser

HändelseUtlöses när
qr.createdEn kod skapas, i appen eller via API:t
qr.publishedEn kod går live och dess omdirigering börjar svara
qr.updatedInnehåll, design, namn eller status ändras
qr.archivedEn kod arkiveras och tas bort från omdirigeringsvärden
qr.deletedEn kod raderas permanent
redirect.rule_changedEtt mål eller en målgruppsregel ändras
scan.aggregate_readyEn skanningsaggregering är klar
export.readyEn asynkron export är färdig
approval.changedEn granskning godkänns eller avslås
bulk.completedEtt massjobb blir klart

Payload

När en händelse gäller en QR-kod bär data.qr samma sammanfattning som GET /qr/{id} returnerar — en trigger behöver alltså inte fråga tillbaka efter posten den just fick höra 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"
    }
  }
}

Verifiera en leverans

Varje anrop bär en tidsstämpel och en signatur över både tidsstämpeln och 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

Att avvisa allt vars tidsstämpel ligger långt från din egen klocka är precis det som gör en avlyssnad leverans omöjlig att spela upp igen — hoppa inte över den kontrollen. Jämför signaturer i konstant tid och använd anropets råa kropp: att serialisera om den tolkade JSON-strukturen ändrar byten och då stämmer signaturen inte längre.

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

Omförsök och fel

Svara med vilken 2xx som helst så snart du sparat händelsen; gör arbetet efteråt. Vi försöker om upp till sex gånger med exponentiellt växande mellanrum, så en långsam hanterare blir till dubbletter — behandla X-Mosaqo-Delivery som en idempotensnyckel hos dig.

Endpoints måste vara HTTPS på en publik värd. Lokala och privata adresser avvisas redan när prenumerationen skapas.

Utfasad signatur

X-Mosaqo-Signature-V0 bär en äldre signatur beräknad enbart på kroppen. Den kan inte uttrycka skydd mot uppspelning, följer med en release så att befintliga mottagare hinner byta, och ska inte användas till något nytt.

Skanningsdata skickas inte ut

Det finns medvetet ingen webhook per skanning. Mosaqo skickar inte analys på skanningsnivå till tredjeparts-endpoints, och ingen händelse i tabellen ovan bär sådan.

Skanningshistoriken är bara tillgänglig genom att du hämtar den själv med din egen nyckel — GET /scans — och även då styrs den av arbetsytans integritetsinställningar för analys: dimensioner du stängt av saknas i varje rad, botfiltret gäller, och den pseudonyma besökarhashen bakom ”unika skanningar” lämnas aldrig ut. Svaret listar allowedDimensions så att du vet vad du får.

scan.aggregate_ready säger att en aggregering är klar; den innehåller inte de underliggande skanningarna. Behöver din integration verkligen enskilda skanningar i realtid går det att ordna på begäran — prata med oss i stället för att utgå från att det finns.

Om du inte kan ta emot webhooks

Fråga i stället. GET /qr?updatedSince=… ger ändrade koder, och GET /scans?since=… går framåt genom skanningshistoriken med en stabil markör.