Un webhook envoie une requête HTTPS signée à votre serveur dès qu’un événement se produit dans l’organisation : un membre arrive, part ou est suspendu, un lot de licences est plein, les règles changent, le contrat approche de sa fin. Une organisation a 10 webhooks au plus.
Créer un webhook dans l’administrationLien vers la section Créer un webhook dans l’administration
- Ouvrez l’administration › « API et clés », carte « Webhooks », puis cliquez sur « Nouveau webhook ».
- Saisissez l’« Adresse » : une adresse HTTPS publique de votre serveur.
- Cochez les « Événements » à recevoir (« Tout choisir » les coche tous) et, si vous voulez, une « Description ».
- Cliquez sur « Créer le webhook ».
- Copiez le « Secret de signature » (il commence par
whsec_), puis cliquez sur « Je l’ai copié ». Il ne sera plus affiché.
Le menu de chaque webhook propose « Envoyer un test » (un événement ping, avec la réponse de votre serveur), « Voir les envois » (l’historique des 30 derniers jours, avec « Renvoyer »), « Modifier », « Créer un nouveau secret », « Désactiver » ou « Activer », et « Supprimer ».
ÉvénementsLien vers la section Événements
| Événement | Envoyé quand |
|---|---|
member.joined | Un membre rejoint l’organisation : invitation acceptée, code d’accès, rattachement, annuaire SCIM. |
member.removed | Un membre est retiré, part de lui-même ou est retiré par l’annuaire. |
member.suspended | Un membre est suspendu, dans l’administration ou par l’annuaire. |
member.reactivated | Un membre suspendu est réactivé. |
license.pool_full | Un lot de licences n’a plus de place libre. |
policy.updated | Les règles de l’organisation, d’un rôle, d’un groupe ou d’un membre changent. |
contract.renewal_due | Le contrat de l’organisation approche de sa fin sans renouvellement signé. |
ping | Un test envoyé depuis l’administration ou par l’API. On ne s’y abonne pas. |
Un même événement produit dans les 5 secondes (même type, même membre, mêmes détails) n’est envoyé qu’une fois.
Ce que reçoit votre serveurLien vers la section Ce que reçoit votre serveur
Une requête POST avec ces en-têtes :
| En-tête | Valeur |
|---|---|
Content-Type | application/ |
User-Agent | MoonAI-Webhooks/ |
Moon-Event | Le type de l’événement, par exemple member.joined. |
Moon-Webhook-Id | L’identifiant de l’événement, le même à chaque nouvel essai. |
Moon-Delivery-Id | L’identifiant de cet envoi. |
Moon-Timestamp | L’heure de l’envoi, en secondes Unix. |
Moon-Signature | t= |
Et ce corps :
{
"id": "5c0e3f1a-6b0f-4f7e-9a51-2a8e3c9d7b10",
"type": "member.joined",
"created_at": 1793520000,
"org_id": "8c1f2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
"data": {
"actor": "3f2b9c1d-7e4a-4b8c-9d0e-1f2a3b4c5d6e",
"member": {
"user_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Camille Martin",
"email": "camille.martin@ecole.fr"
}
}
}| Champ | Description |
|---|---|
id | Identifiant de l’événement (égal à Moon-Webhook-Id). |
type | Le type de l’événement. |
created_at | Heure de l’événement, en secondes Unix. |
org_id | L’organisation. |
data.actor | Qui a fait l’action : l’identifiant d’une personne, api_key:<id> pour une clé d’administration, ou null. |
Selon l’événement, data contient aussi :
| Événement | Champs de data |
|---|---|
member.* | member (user_id, name, email), et source, status ou reason quand ils sont connus. reason vaut left quand la personne est partie d’elle-même. |
policy.updated | scope_type (org, role, group ou member) et scope_id. |
license.pool_full | pool_id, plan_id, plan_name, seats, used et fallback_pool_id. |
contract.renewal_due | contract_id, days_left et period_end. |
ping | message. |
Un corps de plus de 64 Ko est envoyé avec "data": : relisez alors l’information par l’API d’administration.
Vérifier la signatureLien vers la section Vérifier la signature
v1 est le HMAC-SHA256 du texte <t>.<corps , avec votre secret de signature comme clé, écrit en hexadécimal minuscule. Calculez-le sur les octets reçus, avant tout décodage du JSON, comparez en temps constant, et refusez une requête dont t s’écarte de plus de 5 minutes de votre horloge.
import hashlib
import hmac
import time
def verify_moon_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
try:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
expected = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
message = f"{timestamp}.".encode("ascii") + raw_body
computed = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, expected)import crypto from 'node:crypto';
export function verifyMoonSignature(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((item) => item.split('=', 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const computed = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(computed);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Répondre, nouveaux essais et désactivationLien vers la section Répondre, nouveaux essais et désactivation
- Répondez par un code
2xxen moins de 10 secondes. Faites le travail long après avoir répondu. - Sinon, l’envoi est réessayé après 1 minute, 5 minutes, 30 minutes, 2 heures, 6 heures, 12 heures puis 24 heures : 8 essais au plus, puis l’envoi passe en échec.
- Une réponse
410arrête l’envoi et désactive le webhook tout de suite (disabled_reason:gone). - Après 100 essais en échec d’affilée, le webhook est désactivé (
disabled_reason:too_many_failures). Le réactiver remet ce compte à zéro. - Un webhook désactivé ne reçoit plus rien, sauf les tests.
- Un
pingn’est jamais réessayé. - Le même événement peut arriver plus d’une fois : dédoublonnez avec
Moon-Webhook-Id.
L’adresse doit être publique sur Internet. Une adresse privée ou locale est refusée à l’enregistrement, et un envoi vers une adresse devenue privée échoue sans nouvel essai. Les redirections ne sont pas suivies.
Gérer les webhooks par l’APILien vers la section Gérer les webhooks par l’API
Toutes les routes sont sous https:// et demandent une clé d’administration avec la portée webhooks:read (lectures) ou webhooks:write (écritures et lectures). Voir l’authentification.
L’objet webhookLien vers la section L’objet webhook
{
"id": "0b7e6d5c-4a3b-4c2d-9e1f-a0b1c2d3e4f5",
"url": "https://rh.example.fr/moonai/webhook",
"description": "Logiciel RH",
"events": ["member.joined", "member.removed"],
"secret_prefix": "whsec_Ab3dE9",
"active": true,
"disabled_reason": null,
"failure_count": 0,
"last_success_at": 1793520012,
"last_failure_at": null,
"created_at": 1793433600,
"updated_at": 1793433600
}| Champ | Type | Description |
|---|---|---|
id | chaîne | Identifiant du webhook. |
url | chaîne | Adresse HTTPS de votre serveur. |
description | chaîne ou null | Description libre. |
events | liste | Événements reçus, triés. |
secret_prefix | chaîne | Début du secret (whsec_ et six caractères), pour le reconnaître. |
active | booléen | Le webhook reçoit les événements. |
disabled_reason | chaîne ou null | manual, gone ou too_many_failures quand il est désactivé. |
failure_count | entier | Essais en échec d’affilée. |
last_success_at, last_failure_at | entier ou null | Dernier essai réussi, dernier essai en échec. |
created_at, updated_at | entier | Création, dernière modification. |
Lister les webhooksLien vers la section Lister les webhooks
GET / · webhooks:read
Paramètres de requête : page et limit (pagination). Les webhooks les plus récents d’abord. La réponse ajoute events, la liste des événements auxquels on peut s’abonner.
{
"items": [{ "id": "0b7e6d5c-4a3b-4c2d-9e1f-a0b1c2d3e4f5", "url": "https://rh.example.fr/moonai/webhook", "active": true }],
"total": 1,
"page": 1,
"limit": 50,
"events": ["member.joined", "member.removed", "member.suspended", "member.reactivated", "license.pool_full", "policy.updated", "contract.renewal_due"]
}Créer un webhookLien vers la section Créer un webhook
POST / · webhooks:write
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
url | chaîne | oui | Adresse https:// publique, 8 à 2 048 caractères. |
events | liste de chaînes | oui | 1 à 20 événements de la liste ci-dessus. Les doublons sont retirés. |
description | chaîne | non | 200 caractères au plus. |
La réponse est l’objet webhook avec, en plus, secret : le secret de signature complet, rendu cette seule fois.
curl https://chat.realmoon.ai/api/v1/orgs/$ORG_ID/webhooks \
-H "Authorization: Bearer $MOONAI_ADMIN_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-rh-0001" \
-d '{
"url": "https://rh.example.fr/moonai/webhook",
"events": ["member.joined", "member.removed"],
"description": "Logiciel RH"
}'import os
import uuid
import requests
r = requests.post(
f"https://chat.realmoon.ai/api/v1/orgs/{os.environ['ORG_ID']}/webhooks",
headers={
"Authorization": f"Bearer {os.environ['MOONAI_ADMIN_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"url": "https://rh.example.fr/moonai/webhook",
"events": ["member.joined", "member.removed"],
"description": "Logiciel RH",
},
timeout=30,
)
r.raise_for_status()
hook = r.json()
print("Secret à garder :", hook["secret"])import crypto from 'node:crypto';
const r = await fetch(`https://chat.realmoon.ai/api/v1/orgs/${process.env.ORG_ID}/webhooks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MOONAI_ADMIN_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
url: 'https://rh.example.fr/moonai/webhook',
events: ['member.joined', 'member.removed'],
description: 'Logiciel RH',
}),
});
if (!r.ok) throw new Error(await r.text());
const hook = await r.json();
console.log('Secret à garder :', hook.secret);| Statut | code | Cause |
|---|---|---|
| 400 | invalid_request | fields vise url (adresse invalide, pas en https://, pas publique, ou qui ne répond pas pour l’instant) ou events (événement inconnu, liste vide). |
| 409 | webhook_limit | L’organisation a déjà 10 webhooks. |
Lire un webhookLien vers la section Lire un webhook
GET / · webhooks:read
Rend l’objet webhook, sans le secret. 404 si l’identifiant est inconnu.
Modifier un webhookLien vers la section Modifier un webhook
PATCH / · webhooks:write
Envoyez seulement les champs à changer, au moins un.
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
url | chaîne | non | Nouvelle adresse, mêmes règles qu’à la création. |
events | liste de chaînes | non | Remplace la liste, 1 à 20 événements. |
description | chaîne | non | 200 caractères au plus ; une chaîne vide l’efface. |
active | booléen | non | false désactive le webhook (disabled_reason : manual) ; true le réactive et remet failure_count à zéro. |
Rend l’objet webhook. Un corps vide répond 400 (« Rien à modifier. »).
Supprimer un webhookLien vers la section Supprimer un webhook
DELETE / · webhooks:write
Supprime le webhook et l’historique de ses envois. Réponse : {"id": .
Créer un nouveau secretLien vers la section Créer un nouveau secret
POST / · webhooks:write
Remplace le secret de signature. L’ancien cesse de fonctionner tout de suite : donnez le nouveau à votre serveur juste après. La réponse est l’objet webhook avec secret, rendu cette seule fois.
Envoyer un testLien vers la section Envoyer un test
POST / · webhooks:write
Envoie tout de suite un événement ping à ce webhook, même désactivé, et rend l’envoi (voir ci-dessous) après la réponse de votre serveur. Les tests et les renvois comptent dans la limite de 20 tests en 10 minutes par personne (pour une clé, son créateur) : au-delà, 429 .
Lister les envoisLien vers la section Lister les envois
GET / · webhooks:read
| Paramètre | Type | Description |
|---|---|---|
status | chaîne | Filtre : pending, sending, delivered ou failed. Toute autre valeur répond 400. |
page, limit | entier | Pagination. |
Les envois sont gardés 30 jours. Chaque envoi :
{
"id": "e4d3c2b1-a0f9-4e8d-7c6b-5a4f3e2d1c0b",
"event": "member.joined",
"event_id": "5c0e3f1a-6b0f-4f7e-9a51-2a8e3c9d7b10",
"status": "pending",
"attempts": 2,
"next_attempt_at": 1793520300,
"last_status_code": 503,
"last_error": "http_error",
"last_response": "Service Unavailable",
"duration_ms": 182,
"created_at": 1793520000,
"delivered_at": null
}| Champ | Description |
|---|---|
status | pending (en attente d’un essai), sending (en cours), delivered (remis) ou failed (en échec). |
attempts | Essais déjà faits. |
next_attempt_at | Heure du prochain essai, ou null. |
last_status_code | Code HTTP de la dernière réponse, ou null sans réponse. |
last_error | http_error (réponse non 2xx), timeout (pas de réponse en 10 secondes), blocked_address (adresse non publique), webhook_disabled, ou le type d’erreur réseau. |
last_response | Les 500 premiers caractères de la réponse de votre serveur. |
duration_ms | Durée du dernier essai, en millisecondes. |
Renvoyer un envoiLien vers la section Renvoyer un envoi
POST / · webhooks:write
Renvoie tout de suite un envoi remis, en attente ou en échec, et rend l’envoi après l’essai. Ses essais repartent de zéro : s’il échoue, le calendrier des nouveaux essais reprend. Un webhook désactivé ne le reçoit pas : l’envoi passe en échec avec webhook_disabled. 409 si un essai de cet envoi est en cours. Compte dans la limite des tests.