A webhook sends a signed HTTPS request to your server as soon as something happens in the organization: a member joins, leaves or is suspended, a license pool is full, the rules change, the contract is nearing its end. An organization can have up to 10 webhooks.
Create a webhook in the admin consoleLink to the Create a webhook in the admin console section
- Open the admin console › “API and keys”, go to the “Webhooks” card, then click “New webhook”.
- Enter the “Address”: a public HTTPS address of your server.
- Check the “Events” you want to receive (“Select all” checks them all) and, if you like, add a “Description”.
- Click “Create webhook”.
- Copy the “Signing secret” (it starts with
whsec_), then click “I copied it”. It will not be shown again.
Each webhook’s menu offers “Send a test” (a ping event, with your server’s response), “View deliveries” (the history of the last 30 days, with “Resend”), “Edit”, “Create new secret”, “Turn off” or “Turn on”, and “Delete”.
EventsLink to the Events section
| Event | Sent when |
|---|---|
member.joined | A member joins the organization: accepted invitation, access code, automatic joining, SCIM directory. |
member.removed | A member is removed, leaves on their own or is removed by the directory. |
member.suspended | A member is suspended, in the admin console or by the directory. |
member.reactivated | A suspended member is reactivated. |
license.pool_full | A license pool has no free seat left. |
policy.updated | The rules of the organization, a role, a group or a member change. |
contract.renewal_due | The organization’s contract is nearing its end with no signed renewal. |
ping | A test sent from the admin console or through the API. You cannot subscribe to it. |
The same event occurring within 5 seconds (same type, same member, same details) is only sent once.
What your server receivesLink to the What your server receives section
A POST request with these headers:
| Header | Value |
|---|---|
Content-Type | application/ |
User-Agent | MoonAI-Webhooks/ |
Moon-Event | The event type, for example member.joined. |
Moon-Webhook-Id | The event ID, the same on every retry. |
Moon-Delivery-Id | The ID of this delivery. |
Moon-Timestamp | The time of sending, in Unix seconds. |
Moon-Signature | t= |
And this body:
{
"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"
}
}
}| Field | Description |
|---|---|
id | The event ID (equal to Moon-Webhook-Id). |
type | The event type. |
created_at | Time of the event, in Unix seconds. |
org_id | The organization. |
data.actor | Who took the action: a person’s ID, api_key:<id> for an admin key, or null. |
Depending on the event, data also contains:
| Event | Fields in data |
|---|---|
member.* | member (user_id, name, email), plus source, status or reason when known. reason is left when the person left on their own. |
policy.updated | scope_type (org, role, group or member) and scope_id. |
license.pool_full | pool_id, plan_id, plan_name, seats, used and fallback_pool_id. |
contract.renewal_due | contract_id, days_left and period_end. |
ping | message. |
A body larger than 64 KB is sent with "data": : read the information again through the admin API.
Verify the signatureLink to the Verify the signature section
v1 is the HMAC-SHA256 of the text <t>.<raw , keyed with your signing secret and written in lowercase hexadecimal. Compute it on the bytes you received, before any JSON decoding, compare in constant time, and reject a request whose t is more than 5 minutes away from your clock.
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);
}Responses, retries and deactivationLink to the Responses, retries and deactivation section
- Respond with a
2xxcode within 10 seconds. Do any long work after responding. - Otherwise, the delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and then 24 hours: 8 attempts at most, after which the delivery is marked as failed.
- A
410response stops the delivery and turns the webhook off immediately (disabled_reason:gone). - After 100 failed attempts in a row, the webhook is turned off (
disabled_reason:too_many_failures). Turning it back on resets this counter. - A webhook that is turned off receives nothing, except tests.
- A
pingis never retried. - The same event can arrive more than once: deduplicate with
Moon-Webhook-Id.
The address must be publicly reachable on the internet. A private or local address is refused when you save it, and a delivery to an address that has become private fails with no retry. Redirects are not followed.
Manage webhooks through the APILink to the Manage webhooks through the API section
All routes live under https:// and require an admin key with the webhooks:read scope (reads) or webhooks:write (writes and reads). See authentication.
The webhook objectLink to the The webhook object section
{
"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
}| Field | Type | Description |
|---|---|---|
id | string | The webhook ID. |
url | string | The HTTPS address of your server. |
description | string or null | Free-form description. |
events | list | The events received, sorted. |
secret_prefix | string | The start of the secret (whsec_ and six characters), so you can recognize it. |
active | boolean | The webhook receives events. |
disabled_reason | string or null | manual, gone or too_many_failures when it is turned off. |
failure_count | integer | Failed attempts in a row. |
last_success_at, last_failure_at | integer or null | Last successful attempt, last failed attempt. |
created_at, updated_at | integer | Creation, last change. |
List webhooksLink to the List webhooks section
GET / · webhooks:read
Query parameters: page and limit (pagination). Most recent webhooks first. The response adds events, the list of events you can subscribe to.
{
"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"]
}Create a webhookLink to the Create a webhook section
POST / · webhooks:write
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | A public https:// address, 8 to 2,048 characters. |
events | list of strings | yes | 1 to 20 events from the list above. Duplicates are removed. |
description | string | no | 200 characters at most. |
The response is the webhook object plus secret: the full signing secret, returned this one time only.
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 to keep:", 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 to keep:', hook.secret);| Status | code | Cause |
|---|---|---|
| 400 | invalid_request | fields points to url (invalid address, not https://, not public, or not responding right now) or to events (unknown event, empty list). |
| 409 | webhook_limit | The organization already has 10 webhooks. |
Get a webhookLink to the Get a webhook section
GET / · webhooks:read
Returns the webhook object, without the secret. 404 if the ID is unknown.
Update a webhookLink to the Update a webhook section
PATCH / · webhooks:write
Send only the fields to change, at least one.
| Field | Type | Required | Description |
|---|---|---|---|
url | string | no | New address, same rules as at creation. |
events | list of strings | no | Replaces the list, 1 to 20 events. |
description | string | no | 200 characters at most; an empty string clears it. |
active | boolean | no | false turns the webhook off (disabled_reason: manual); true turns it back on and resets failure_count to zero. |
Returns the webhook object. An empty body returns 400 with the message Rien (nothing to change).
Delete a webhookLink to the Delete a webhook section
DELETE / · webhooks:write
Deletes the webhook and the history of its deliveries. Response: {"id": .
Create a new secretLink to the Create a new secret section
POST / · webhooks:write
Replaces the signing secret. The old one stops working immediately: give the new one to your server right away. The response is the webhook object with secret, returned this one time only.
Send a testLink to the Send a test section
POST / · webhooks:write
Immediately sends a ping event to this webhook, even if it is turned off, and returns the delivery (see below) once your server has responded. Tests and resends count toward the limit of 20 tests in 10 minutes per person (for a key, its creator): beyond that, 429 .
List deliveriesLink to the List deliveries section
GET / · webhooks:read
| Parameter | Type | Description |
|---|---|---|
status | string | Filter: pending, sending, delivered or failed. Any other value returns 400. |
page, limit | integer | Pagination. |
Deliveries are kept for 30 days. Each delivery:
{
"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
}| Field | Description |
|---|---|
status | pending (waiting for an attempt), sending (in progress), delivered or failed. |
attempts | Attempts already made. |
next_attempt_at | Time of the next attempt, or null. |
last_status_code | HTTP code of the last response, or null when there was no response. |
last_error | http_error (non-2xx response), timeout (no response within 10 seconds), blocked_address (non-public address), webhook_disabled, or the type of network error. |
last_response | The first 500 characters of your server’s response. |
duration_ms | Duration of the last attempt, in milliseconds. |
Resend a deliveryLink to the Resend a delivery section
POST / · webhooks:write
Immediately resends a delivered, pending or failed delivery, and returns the delivery after the attempt. Its attempts start over from zero: if it fails, the retry schedule starts again. A webhook that is turned off does not receive it: the delivery is marked as failed with webhook_disabled. 409 if an attempt of this delivery is already running. Counts toward the test limit.