DocsOpen Moon AI
Browse the docs

Organization webhooks

Receive the organization’s events as signed requests, verify the signature, handle retries and manage your webhooks through the admin API.

On this page

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

  1. Open the admin console › “API and keys”, go to the “Webhooks” card, then click “New webhook”.
  2. Enter the “Address”: a public HTTPS address of your server.
  3. Check the “Events” you want to receive (“Select all” checks them all) and, if you like, add a “Description”.
  4. Click “Create webhook”.
  5. 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

EventSent when
member.joinedA member joins the organization: accepted invitation, access code, automatic joining, SCIM directory.
member.removedA member is removed, leaves on their own or is removed by the directory.
member.suspendedA member is suspended, in the admin console or by the directory.
member.reactivatedA suspended member is reactivated.
license.pool_fullA license pool has no free seat left.
policy.updatedThe rules of the organization, a role, a group or a member change.
contract.renewal_dueThe organization’s contract is nearing its end with no signed renewal.
pingA 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:

HeaderValue
Content-Typeapplication/json
User-AgentMoonAI-Webhooks/1.0
Moon-EventThe event type, for example member.joined.
Moon-Webhook-IdThe event ID, the same on every retry.
Moon-Delivery-IdThe ID of this delivery.
Moon-TimestampThe time of sending, in Unix seconds.
Moon-Signaturet=<time>,v1=<signature>

And this body:

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"
    }
  }
}
FieldDescription
idThe event ID (equal to Moon-Webhook-Id).
typeThe event type.
created_atTime of the event, in Unix seconds.
org_idThe organization.
data.actorWho took the action: a person’s ID, api_key:<id> for an admin key, or null.

Depending on the event, data also contains:

EventFields 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.updatedscope_type (org, role, group or member) and scope_id.
license.pool_fullpool_id, plan_id, plan_name, seats, used and fallback_pool_id.
contract.renewal_duecontract_id, days_left and period_end.
pingmessage.

A body larger than 64 KB is sent with "data": {"truncated": true}: 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 body>, 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.

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
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 2xx code 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 410 response 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 ping is 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://chat.realmoon.ai/api/v1/orgs/{org_id} 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

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
}
FieldTypeDescription
idstringThe webhook ID.
urlstringThe HTTPS address of your server.
descriptionstring or nullFree-form description.
eventslistThe events received, sorted.
secret_prefixstringThe start of the secret (whsec_ and six characters), so you can recognize it.
activebooleanThe webhook receives events.
disabled_reasonstring or nullmanual, gone or too_many_failures when it is turned off.
failure_countintegerFailed attempts in a row.
last_success_at, last_failure_atinteger or nullLast successful attempt, last failed attempt.
created_at, updated_atintegerCreation, last change.

List webhooksLink to the List webhooks section

GET /webhooks · webhooks:read

Query parameters: page and limit (pagination). Most recent webhooks first. The response adds events, the list of events you can subscribe to.

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

Create a webhookLink to the Create a webhook section

POST /webhooks · webhooks:write

FieldTypeRequiredDescription
urlstringyesA public https:// address, 8 to 2,048 characters.
eventslist of stringsyes1 to 20 events from the list above. Duplicates are removed.
descriptionstringno200 characters at most.

The response is the webhook object plus secret: the full signing secret, returned this one time only.

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
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"])
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 to keep:', hook.secret);
StatuscodeCause
400invalid_requestfields points to url (invalid address, not https://, not public, or not responding right now) or to events (unknown event, empty list).
409webhook_limitThe organization already has 10 webhooks.

Get a webhookLink to the Get a webhook section

GET /webhooks/{webhook_id} · webhooks:read

Returns the webhook object, without the secret. 404 not_found if the ID is unknown.

Update a webhookLink to the Update a webhook section

PATCH /webhooks/{webhook_id} · webhooks:write

Send only the fields to change, at least one.

FieldTypeRequiredDescription
urlstringnoNew address, same rules as at creation.
eventslist of stringsnoReplaces the list, 1 to 20 events.
descriptionstringno200 characters at most; an empty string clears it.
activebooleannofalse 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 invalid_request with the message Rien à modifier. (nothing to change).

Delete a webhookLink to the Delete a webhook section

DELETE /webhooks/{webhook_id} · webhooks:write

Deletes the webhook and the history of its deliveries. Response: {"id": "…", "deleted": true}.

Create a new secretLink to the Create a new secret section

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

List deliveriesLink to the List deliveries section

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

ParameterTypeDescription
statusstringFilter: pending, sending, delivered or failed. Any other value returns 400.
page, limitintegerPagination.

Deliveries are kept for 30 days. Each delivery:

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
}
FieldDescription
statuspending (waiting for an attempt), sending (in progress), delivered or failed.
attemptsAttempts already made.
next_attempt_atTime of the next attempt, or null.
last_status_codeHTTP code of the last response, or null when there was no response.
last_errorhttp_error (non-2xx response), timeout (no response within 10 seconds), blocked_address (non-public address), webhook_disabled, or the type of network error.
last_responseThe first 500 characters of your server’s response.
duration_msDuration of the last attempt, in milliseconds.

Resend a deliveryLink to the Resend a delivery section

POST /webhooks/{webhook_id}/deliveries/{delivery_id}/retry · 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 delivery_in_progress if an attempt of this delivery is already running. Counts toward the test limit.

See alsoLink to the See also section

Moon AI

Moon AI in action

0:00

Explore

Screenshots of chat.realmoon.ai taken on October 29, 2026, with fictional data.

Moon AI

Your preferences

Visits counted without cookies.

Our server counts visits without cookies or trackers. Your theme and your answer about ad measurement are remembered on this device.

Ad measurement

With your consent, our ad networks’ trackers (Google) measure our ads, from the visit to the sign-up and then to the first subscription and its amount, and may show you our ads. Your answer is valid for 13 months; you can change it here at any time.

No answer saved on this device.

After you decline, no tag is loaded anymore and the site deletes the cookies they had set on realmoon.ai. Those on the ad networks’ own domains are managed with them or in your browser.

The Moon AI app has its own privacy settings.