Mosavo
Se connecterCréer ma page
Documentation

Webhooks

Un webhook envoie une requête HTTP signée à ton serveur dès qu'un événement se produit sur tes pages, tes équipes ou tes demandes de support.

Sommaire

Créer un webhook

Ajoute un webhook dans Réglages, Clés API et webhooks, ou avec POST /api/v1/me/webhooks (portée webhooks:write). Choisis l'adresse en https, les événements et, si tu es admin d'une équipe, l'équipe concernée. Le secret de signature n'est affiché qu'une fois : garde le dans les variables d'environnement de ton serveur. Le nombre de webhooks dépend de ton palier (tableau des limites du guide).

curl -X POST https://mosavo.fr/api/v1/me/webhooks \
  -H "Authorization: Bearer msk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks/mosavo","events":["page.published","message.received"]}'

Événements

Chaque webhook ne reçoit que les événements cochés. Un webhook d'équipe reçoit les événements des pages et des membres de l'équipe ; un webhook personnel ceux de tes propres pages et de tes demandes de support. test.ping part uniquement avec le bouton « Envoyer un test ».

  • page.publishedPage publiée
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace",
        "url": "https://mosavo.fr/ada"
      }
    }
  • page.unpublishedPage dépubliée
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      }
    }
  • page.updatedRéglages de page modifiés
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "changes": [
        "title",
        "theme"
      ]
    }
  • block.createdBloc ajouté
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "block": {
        "id": "clb0example",
        "type": "LINK"
      }
    }
  • block.updatedBloc modifié
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "block": {
        "id": "clb0example",
        "type": "LINK"
      }
    }
  • block.deletedBloc supprimé
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "block": {
        "id": "clb0example",
        "type": "LINK"
      }
    }
  • message.receivedMessage reçu
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "message": {
        "id": "clm0example",
        "blockId": "clb1example",
        "name": "Grace",
        "email": "[email protected]",
        "subject": "Hello",
        "body": "I love your page.",
        "receivedAt": "2026-11-01T12:00:00.000Z"
      }
    }
  • follower.addedNouvel abonné
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "follower": {
        "id": "clx1example",
        "pseudo": "grace"
      },
      "followers": 12
    }
  • follower.removedAbonné parti
    {
      "page": {
        "id": "clp0example",
        "slug": "ada",
        "title": "Ada Lovelace"
      },
      "follower": {
        "id": "clx1example",
        "pseudo": "grace"
      },
      "followers": 11
    }
  • team.member_joinedMembre arrivé
    {
      "team": {
        "id": "clt0example",
        "name": "Studio"
      },
      "member": {
        "id": "clx1example",
        "pseudo": "grace",
        "role": "EDITOR"
      }
    }
  • team.member_leftMembre parti
    {
      "team": {
        "id": "clt0example",
        "name": "Studio"
      },
      "member": {
        "id": "clx1example",
        "pseudo": "grace"
      },
      "removedBy": "self"
    }
  • ticket.repliedRéponse du support
    {
      "ticket": {
        "reference": "MOS-4F2K9",
        "subject": "Custom domain",
        "status": "PENDING"
      },
      "reply": {
        "body": "Your domain is now verified.",
        "repliedAt": "2026-11-01T12:00:00.000Z"
      }
    }
  • test.pingTest
    {
      "message": "This is a test event sent from Mosavo.",
      "webhookId": "clw0example"
    }

Format de la requête

Mosavo envoie un POST avec un corps JSON avec les champs id, event, createdAt et data. id identifie l'événement et reste le même lors d'un renvoi : sers t'en pour ignorer les doublons. En têtes : Mosavo-Event, Mosavo-Delivery (identifiant de la livraison), Mosavo-Signature et User-Agent: Mosavo-Webhooks/1.0. Réponds par un code 2xx en moins de 10 secondes ; les redirections ne sont pas suivies et seuls les 64 premiers Ko de la réponse sont lus.

POST /webhooks/mosavo HTTP/1.1
Content-Type: application/json
User-Agent: Mosavo-Webhooks/1.0
Mosavo-Event: page.published
Mosavo-Delivery: cld0example
Mosavo-Signature: t=1793361660,v1=5f0c…

{
  "id": "evt_3f9a1c",
  "event": "page.published",
  "createdAt": "2026-11-01T12:00:00.000Z",
  "data": {
    "page": {
      "id": "clp0example",
      "slug": "ada",
      "title": "Ada Lovelace",
      "url": "https://mosavo.fr/ada"
    }
  }
}

Vérifier la signature

Mosavo-Signature vaut t=TIMESTAMP,v1=SIGNATURE. La signature est le HMAC SHA 256, en hexadécimal, de la chaîne timestamp.corps avec ton secret. Calcule la sur le corps brut, avant tout décodage JSON, compare en temps constant et refuse un horodatage de plus de 5 minutes.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto"
import express from "express"

const secret = process.env.MOSAVO_WEBHOOK_SECRET

function verify(header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")))
  const timestamp = Number(parts.t)
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest()
  const given = Buffer.from(parts.v1 ?? "", "hex")
  return given.length === expected.length && timingSafeEqual(given, expected)
}

const app = express()
app.post("/webhooks/mosavo", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("Mosavo-Signature") ?? "", req.body.toString("utf8"))) return res.sendStatus(400)
  const event = JSON.parse(req.body)
  console.log(event.event, event.data)
  res.sendStatus(204)
})

Python

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

secret = os.environ["MOSAVO_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

def verify(header: str, raw_body: bytes) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > 300:
        return False
    expected = hmac.new(secret, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/webhooks/mosavo")
def mosavo():
    if not verify(request.headers.get("Mosavo-Signature", ""), request.get_data()):
        abort(400)
    event = json.loads(request.get_data())
    print(event["event"], event["data"])
    return "", 204

Nouvelles tentatives

Une livraison réussit avec un code 2xx. Sinon (erreur, délai dépassé, redirection, code 4xx ou 5xx), Mosavo réessaie après 1 min, 5 min, 30 min, 2 h, 6 h, 12 h puis 24 h, soit 8 tentatives au total, puis la marque en échec. Le journal des 50 dernières livraisons, conservé 30 jours, permet de voir la réponse et de renvoyer une livraison.

2. +1 min
3. +5 min
4. +30 min
5. +2 h
6. +6 h
7. +12 h
8. +24 h

Désactivation automatique

Après 20 échecs d'affilée, le webhook se désactive et tu reçois une notification. Corrige ton serveur, envoie un test, puis réactive le : le compteur repart de zéro. L'équipe Mosavo peut aussi désactiver un webhook abusif ; il ne peut alors être réactivé que par le support.

Sécurité

Les adresses doivent être en https et publiques : les adresses locales, privées ou réservées sont refusées à l'enregistrement et à chaque envoi, même après une résolution DNS. Le secret est stocké chiffré et n'est jamais renvoyé par l'API ; régénère le s'il a fuité.