# Webhooks de l’organisation

> Recevoir les événements de l’organisation dans une requête signée, vérifier la signature, gérer les nouveaux essais et piloter les webhooks par l’API.

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’administration

1. Ouvrez l’administration › « API et clés », carte « Webhooks », puis cliquez sur « Nouveau webhook ».
2. Saisissez l’« Adresse » : une adresse HTTPS publique de votre serveur.
3. Cochez les « Événements » à recevoir (« Tout choisir » les coche tous) et, si vous voulez, une « Description ».
4. Cliquez sur « Créer le webhook ».
5. 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é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 serveur

Une requête `POST` avec ces en-têtes :

| En-tête | Valeur |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `MoonAI-Webhooks/1.0` |
| `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=<heure>,v1=<signature>` |

Et ce corps :

```json
{
  "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": {"truncated": true}` : relisez alors l’information par l’[API d’administration](https://realmoon.ai/documentation/api-organisations/administration/).

## Vérifier la signature

`v1` est le HMAC-SHA256 du texte `<t>.<corps brut>`, 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.

```python title="Python"
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)
```

```javascript title="JavaScript"
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ésactivation

- Répondez par un code `2xx` en 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 `410` arrê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 `ping` n’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’API

Toutes les routes sont sous `https://chat.realmoon.ai/api/v1/orgs/{org_id}` et demandent une clé d’administration avec la portée `webhooks:read` (lectures) ou `webhooks:write` (écritures et lectures). Voir [l’authentification](https://realmoon.ai/documentation/api-organisations/administration/#authentification).

### L’objet webhook

```json
{
  "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 webhooks

`GET /webhooks` · `webhooks:read`

Paramètres de requête : `page` et `limit` ([pagination](https://realmoon.ai/documentation/api-organisations/administration/#pagination)). Les webhooks les plus récents d’abord. La réponse ajoute `events`, la liste des événements auxquels on peut s’abonner.

```json
{
  "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 webhook

`POST /webhooks` · `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.

```bash title="cURL"
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"
  }'
```

```python title="Python"
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"])
```

```javascript title="JavaScript"
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 webhook

`GET /webhooks/{webhook_id}` · `webhooks:read`

Rend l’objet webhook, sans le secret. `404 not_found` si l’identifiant est inconnu.

### Modifier un webhook

`PATCH /webhooks/{webhook_id}` · `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 invalid_request` (« Rien à modifier. »).

### Supprimer un webhook

`DELETE /webhooks/{webhook_id}` · `webhooks:write`

Supprime le webhook et l’historique de ses envois. Réponse : `{"id": "…", "deleted": true}`.

### Créer un nouveau secret

`POST /webhooks/{webhook_id}/rotate-secret` · `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 test

`POST /webhooks/{webhook_id}/test` · `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 rate_limited`.

### Lister les envois

`GET /webhooks/{webhook_id}/deliveries` · `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](https://realmoon.ai/documentation/api-organisations/administration/#pagination). |

Les envois sont gardés 30 jours. Chaque envoi :

```json
{
  "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 envoi

`POST /webhooks/{webhook_id}/deliveries/{delivery_id}/retry` · `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 delivery_in_progress` si un essai de cet envoi est en cours. Compte dans la limite des tests.

## Voir aussi

- [API d’administration](https://realmoon.ai/documentation/api-organisations/administration/)
- [Limites de débit et idempotence](https://realmoon.ai/documentation/api-organisations/administration/#limites)

---

Documentation Moon AI › API des organisations › Webhooks. Page : https://realmoon.ai/documentation/api-organisations/webhooks/. Modifiée le 5 novembre 2026.

© 2026 Stellarr Studio SAS. Tous droits réservés. Moon AI, Moon Blur et Stellarr Studio sont des marques déposées à l’INPI.
