Saltar al contenido

Webhooks de Mosaqo

Enviamos un POST con un cuerpo JSON firmado a su endpoint por cada evento al que esté suscrito, y reintentamos con espera creciente hasta que lo acepte.

Suscribirse

Añada un endpoint en Bulk & API o regístrelo desde su propio código con una clave webhooks:write: así es como una plataforma de automatización enciende y apaga un disparador.

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 respuesta contiene el secret de firma. Solo se muestra una vez: guárdelo o sustituya la suscripción más adelante.

Eventos

EventoSe dispara cuando
qr.createdSe crea un código, en la aplicación o mediante la API
qr.publishedUn código se activa y su redirección empieza a resolver
qr.updatedCambian el contenido, el diseño, el nombre o el estado
qr.archivedSe archiva un código y se retira del host de redirecciones
qr.deletedSe elimina un código de forma permanente
redirect.rule_changedCambia un destino o una regla de segmentación
scan.aggregate_readyUn agregado de escaneos está listo
export.readyUna exportación asíncrona ha terminado
approval.changedUna revisión se aprueba o se rechaza
bulk.completedTermina un trabajo por lotes

Payload

Cuando un evento afecta a un código QR, data.qr lleva el mismo resumen que devuelve GET /qr/{id}, de modo que un disparador no tiene que volver a pedir la ficha de la que acaban de informarle.

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

Verificar una entrega

Cada petición lleva una marca de tiempo y una firma sobre la marca y el cuerpo a la vez:

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

Rechazar todo aquello cuya marca de tiempo esté lejos de su propio reloj es justo lo que hace irrepetible una entrega interceptada, así que no se salte esa comprobación. Compare firmas en tiempo constante y use el cuerpo crudo de la petición: volver a serializar el JSON analizado cambia los bytes y la firma dejará de coincidir.

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

Reintentos y fallos

Responda con cualquier 2xx en cuanto haya guardado el evento y haga el trabajo después. Reintentamos hasta seis veces con espera exponencial, así que un manejador lento se traduce en entregas duplicadas: trate X-Mosaqo-Delivery como clave de idempotencia en su lado.

Los endpoints deben ser HTTPS en un host público. Las direcciones locales y privadas se rechazan al crear la suscripción.

Firma obsoleta

X-Mosaqo-Signature-V0 lleva una firma anterior calculada solo sobre el cuerpo. No puede expresar protección frente a repeticiones, se envía durante una versión para que los receptores existentes migren y no debe usarse para nada nuevo.

Los datos de escaneo no se envían

Deliberadamente no hay webhook por escaneo. Mosaqo no envía analíticas a nivel de escaneo a endpoints de terceros, y ningún evento de la tabla anterior las transporta.

El historial de escaneos solo está disponible si lo obtiene usted mismo con su propia clave —GET /scans— e incluso entonces lo rigen los ajustes de privacidad analítica de su espacio: las dimensiones que haya desactivado no aparecen en ninguna fila, se aplica el filtro de bots y el hash seudónimo del visitante que hay detrás de los «escaneos únicos» nunca se expone. La respuesta lista allowedDimensions para que sepa qué está recibiendo.

scan.aggregate_ready indica que un agregado está listo; no contiene los escaneos subyacentes. Si su integración necesita realmente escaneos individuales en tiempo real, eso está disponible bajo petición: háblenos de ello en lugar de darlo por hecho.

Si no puede recibir webhooks

Consulte periódicamente. GET /qr?updatedSince=… devuelve los códigos modificados y GET /scans?since=… recorre el historial de escaneos hacia adelante con un cursor estable.