Webhooks quickstart
Webhooks push community events to your own HTTPS endpoint as they happen.
You skip polling GET /api/v1/events and react in near-real-time. Same envelope
shape, same event types, same data payloads — see the events reference.
Five-minute setup
- Register an endpoint. Admin → Webhooks → Add endpoint. Paste the
HTTPS URL of your receiver, pick
*(or specific event types), and click create. - Copy the signing secret. It's shown on the success page, exactly once. Mobieus only stores a SHA-256 hash — if you lose it, you create a new endpoint.
- Implement the receiver. Verify the signature, dedupe on
event.id, and return any 2xx within 10 seconds. - Send a test event. From the endpoint detail page, click Send test event.
Within ~60 seconds you'll see a delivery row appear with status
succeeded.
The signed request
Every webhook delivery looks like this:
POST /your-receiver HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Mobieus-Webhook/1.0
Mobieus-Signature: t=1716902400,v1=4f8a0b7c8d2f3e6...
Mobieus-Event-Id: evt_01JG5K8HW2X4A8Q3M1T6KQ7BWP
Mobieus-Event-Type: post.created
{
"id": "evt_01JG5K8HW2X4A8Q3M1T6KQ7BWP",
"type": "post.created",
"created_at": "2026-05-28T12:34:56.123456Z",
"data": {
"post_id": 9182,
"thread_id": 1124,
"forum_slug": "general",
"title": "Welcome to the community",
"author": { "id": 42, "username": "patrick" }
}
}
Verifying the signature
The Mobieus-Signature header is t=<unix>,v1=<hex>. To verify:
- Parse
tandv1out of the header. - Reject if
abs(now - t) > 300seconds (replay protection). - Compute
v1' = HMAC_SHA256(secret, t + '.' + raw_body). - Constant-time-compare
v1andv1'.
Node.js
const crypto = require('crypto');
const SECRET = process.env.MOBIEUS_WEBHOOK_SECRET;
const TOLERANCE_S = 300;
function verifyMobieusSignature(header, rawBody) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)));
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Math.floor(Date.now()/1000) - t) > TOLERANCE_S) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(\`${t}.${rawBody}\`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex')
);
}
// Express:
const express = require('express');
const app = express();
app.post('/mobieus',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyMobieusSignature(
req.headers['mobieus-signature'],
req.body.toString('utf8')
);
if (!ok) return res.status(400).end();
const evt = JSON.parse(req.body);
// Idempotent: skip if you've seen evt.id before.
handle(evt);
res.status(200).end();
}
);
Python
import hmac, hashlib, time
SECRET = os.environ['MOBIEUS_WEBHOOK_SECRET'].encode()
TOLERANCE = 300
def verify_mobieus_signature(header: str, raw_body: bytes) -> bool:
if not header:
return False
parts = dict(p.split('=', 1) for p in header.split(','))
try:
t = int(parts['t'])
v1 = parts['v1']
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE:
return False
expected = hmac.new(
SECRET,
f'{t}.'.encode() + raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask:
from flask import Flask, request
app = Flask(__name__)
@app.post('/mobieus')
def receive():
if not verify_mobieus_signature(
request.headers.get('Mobieus-Signature', ''),
request.get_data()
):
return ('', 400)
evt = request.get_json()
# Idempotent: skip if you've seen evt['id'] before.
handle(evt)
return ('', 200)
Delivery semantics
| Aspect | Behavior |
|---|---|
| Guarantee | At-least-once. Receivers MUST be idempotent on event.id. |
| Ordering | Best-effort. post.created for the same post may arrive AFTER post.edited if the first delivery had to retry. |
| Per-attempt timeout | 10 seconds (configurable per tenant: webhooks.per_attempt_timeout_seconds). |
| Retry schedule | 0s → 1m → 5m → 30m → 2h → 6h → 24h, then status=dead. (Configurable: webhooks.retry_schedule_minutes.) |
| Auto-disable | After 20 consecutive failed deliveries the endpoint is auto-disabled and audit-logged. (Configurable: webhooks.auto_disable_after_failures.) Re-enable in the admin UI. |
What counts as success
Any 2xx HTTP response within the timeout window. Any non-2xx, network error, TLS error, or timeout is treated as a transient failure and retried.
Your receiver should return 200 as soon as it's accepted the event into a local queue — do the heavy work asynchronously. Returning 200 after 9 seconds of in-line processing wastes our timeout budget for no reason.
SSRF protection
Mobieus refuses to call any URL that isn't:
https://(no plain HTTP)- Resolving to a public IP (no
127.0.0.1, no RFC1918, no link-local, no cloud-metadata addresses like169.254.169.254)
This check runs at registration AND again before every delivery, so DNS rebinding tricks can't pivot a previously-allowed hostname onto an internal IP.
Debugging
Every delivery row in /admin/webhooks/{id} shows:
- HTTP status code from your receiver
- Total request latency in ms
- The first 2,000 chars of your response body (for surfacing your own error messages)
- The last error string (cURL or timeout reason)
- Attempt N of M, next-attempt time
Click Send test event to fire a synthetic webhook.test event without
waiting for real traffic. The receiver sees it exactly like a normal delivery.
What resources fire webhooks (as of API 1.4.0)
The public REST API has grown a lot since the first release. Every resource below emits webhook events through the same outbound delivery pipeline (HMAC-signed, retried, replay-protected).
| Surface | Event prefix | Coverage |
|---|---|---|
| Forums | post.*, thread.* |
created, updated, deleted |
| Users | user.* |
registered, role_changed, deactivated |
| Marketplace | listing.* |
created, updated, sold, withdrawn |
| Files | file.* |
uploaded (after quarantine clears) |
| Moderation | report.*, moderation.* |
report.created, report.resolved, moderation.action_taken |
| mobieusHelp | ticket.*, helpdesk.* |
ticket.created, ticket.replied, ticket.status_changed, ticket.assigned (1.3.0+) |
| mobieusLearn | learn.* |
course.published, enrollment.created, attempt.submitted, certificate.issued (1.3.0+) |
| mobieusKnow | know.* |
page.created, revision.approved, page.deleted (1.3.0+) |
| Webhooks | webhook.* |
webhook.test, webhook.endpoint_disabled |
Subscribe to ["*"] to get every event type — new event types added by future minor versions land in your stream automatically.
What's NOT a webhook event (yet)
A few admin surfaces don't fire webhooks today even though they have full REST API coverage:
- LTI 1.3 Advantage services (Phase 4 M1-M2) — AGS / NRPS / Deep Linking flows are server-to-server with the LMS, not customer-observable
- SCIM 2.0 provisioning — has its own RFC 7644 bearer-token surface at
/scim/v2/*; events emitted by the IdP, not by Mobieus - Outbound xAPI bridge — is itself a webhook-style emitter, just one whose destination is a vendor LRS, not a generic HTTP endpoint
- Live sessions (1.4.0) — the schedule + ICS + reminder emails fire from cron, not as outbound webhooks. If you want to mirror scheduled sessions, poll
GET /api/v1/learn/live-sessionson a 5-minute cron until we exposelearn.live_session.scheduledas an event in a future minor.
# Webhooks quickstart
Webhooks **push** community events to your own HTTPS endpoint as they happen.
You skip polling `GET /api/v1/events` and react in near-real-time. Same envelope
shape, same event types, same `data` payloads — see [the events reference](/know/api-events-reference).
## Five-minute setup
1. **Register an endpoint.** Admin → **Webhooks** → *Add endpoint*. Paste the
HTTPS URL of your receiver, pick `*` (or specific event types), and click
create.
2. **Copy the signing secret.** It's shown on the success page, exactly once.
Mobieus only stores a SHA-256 hash — if you lose it, you create a new endpoint.
3. **Implement the receiver.** Verify the signature, dedupe on `event.id`, and
return any 2xx within 10 seconds.
4. **Send a test event.** From the endpoint detail page, click **Send test event**.
Within ~60 seconds you'll see a delivery row appear with status `succeeded`.
## The signed request
Every webhook delivery looks like this:
```
POST /your-receiver HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Mobieus-Webhook/1.0
Mobieus-Signature: t=1716902400,v1=4f8a0b7c8d2f3e6...
Mobieus-Event-Id: evt_01JG5K8HW2X4A8Q3M1T6KQ7BWP
Mobieus-Event-Type: post.created
{
"id": "evt_01JG5K8HW2X4A8Q3M1T6KQ7BWP",
"type": "post.created",
"created_at": "2026-05-28T12:34:56.123456Z",
"data": {
"post_id": 9182,
"thread_id": 1124,
"forum_slug": "general",
"title": "Welcome to the community",
"author": { "id": 42, "username": "patrick" }
}
}
```
## Verifying the signature
The `Mobieus-Signature` header is `t=<unix>,v1=<hex>`. To verify:
1. Parse `t` and `v1` out of the header.
2. Reject if `abs(now - t) > 300` seconds (replay protection).
3. Compute `v1' = HMAC_SHA256(secret, t + '.' + raw_body)`.
4. Constant-time-compare `v1` and `v1'`.
### Node.js
```js
const crypto = require('crypto');
const SECRET = process.env.MOBIEUS_WEBHOOK_SECRET;
const TOLERANCE_S = 300;
function verifyMobieusSignature(header, rawBody) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)));
const t = parseInt(parts.t, 10);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Math.floor(Date.now()/1000) - t) > TOLERANCE_S) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(\`${t}.${rawBody}\`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex')
);
}
// Express:
const express = require('express');
const app = express();
app.post('/mobieus',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyMobieusSignature(
req.headers['mobieus-signature'],
req.body.toString('utf8')
);
if (!ok) return res.status(400).end();
const evt = JSON.parse(req.body);
// Idempotent: skip if you've seen evt.id before.
handle(evt);
res.status(200).end();
}
);
```
### Python
```py
import hmac, hashlib, time
SECRET = os.environ['MOBIEUS_WEBHOOK_SECRET'].encode()
TOLERANCE = 300
def verify_mobieus_signature(header: str, raw_body: bytes) -> bool:
if not header:
return False
parts = dict(p.split('=', 1) for p in header.split(','))
try:
t = int(parts['t'])
v1 = parts['v1']
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE:
return False
expected = hmac.new(
SECRET,
f'{t}.'.encode() + raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask:
from flask import Flask, request
app = Flask(__name__)
@app.post('/mobieus')
def receive():
if not verify_mobieus_signature(
request.headers.get('Mobieus-Signature', ''),
request.get_data()
):
return ('', 400)
evt = request.get_json()
# Idempotent: skip if you've seen evt['id'] before.
handle(evt)
return ('', 200)
```
## Delivery semantics
| Aspect | Behavior |
|---|---|
| **Guarantee** | At-least-once. Receivers MUST be idempotent on `event.id`. |
| **Ordering** | Best-effort. `post.created` for the same post may arrive AFTER `post.edited` if the first delivery had to retry. |
| **Per-attempt timeout** | 10 seconds (configurable per tenant: `webhooks.per_attempt_timeout_seconds`). |
| **Retry schedule** | 0s → 1m → 5m → 30m → 2h → 6h → 24h, then `status=dead`. (Configurable: `webhooks.retry_schedule_minutes`.) |
| **Auto-disable** | After 20 consecutive failed deliveries the endpoint is auto-disabled and audit-logged. (Configurable: `webhooks.auto_disable_after_failures`.) Re-enable in the admin UI. |
## What counts as success
Any **2xx HTTP response** within the timeout window. Any non-2xx, network
error, TLS error, or timeout is treated as a transient failure and retried.
Your receiver should return 200 as soon as it's accepted the event into a
local queue — do the heavy work asynchronously. Returning 200 after 9 seconds
of in-line processing wastes our timeout budget for no reason.
## SSRF protection
Mobieus refuses to call any URL that isn't:
- `https://` (no plain HTTP)
- Resolving to a public IP (no `127.0.0.1`, no RFC1918, no link-local, no
cloud-metadata addresses like `169.254.169.254`)
This check runs at registration AND again before every delivery, so DNS
rebinding tricks can't pivot a previously-allowed hostname onto an internal IP.
## Debugging
Every delivery row in `/admin/webhooks/{id}` shows:
- HTTP status code from your receiver
- Total request latency in ms
- The first 2,000 chars of your response body (for surfacing your own error messages)
- The last error string (cURL or timeout reason)
- Attempt N of M, next-attempt time
Click **Send test event** to fire a synthetic `webhook.test` event without
waiting for real traffic. The receiver sees it exactly like a normal delivery.
---
## What resources fire webhooks (as of API 1.4.0)
The public REST API has grown a lot since the first release. Every resource below emits webhook events through the same outbound delivery pipeline (HMAC-signed, retried, replay-protected).
| Surface | Event prefix | Coverage |
|---|---|---|
| Forums | `post.*`, `thread.*` | created, updated, deleted |
| Users | `user.*` | registered, role_changed, deactivated |
| Marketplace | `listing.*` | created, updated, sold, withdrawn |
| Files | `file.*` | uploaded (after quarantine clears) |
| Moderation | `report.*`, `moderation.*` | report.created, report.resolved, moderation.action_taken |
| mobieusHelp | `ticket.*`, `helpdesk.*` | ticket.created, ticket.replied, ticket.status_changed, ticket.assigned (1.3.0+) |
| mobieusLearn | `learn.*` | course.published, enrollment.created, attempt.submitted, certificate.issued (1.3.0+) |
| mobieusKnow | `know.*` | page.created, revision.approved, page.deleted (1.3.0+) |
| Webhooks | `webhook.*` | webhook.test, webhook.endpoint_disabled |
Subscribe to `["*"]` to get every event type — new event types added by future minor versions land in your stream automatically.
## What's NOT a webhook event (yet)
A few admin surfaces don't fire webhooks today even though they have full REST API coverage:
- **LTI 1.3 Advantage services** (Phase 4 M1-M2) — AGS / NRPS / Deep Linking flows are server-to-server with the LMS, not customer-observable
- **SCIM 2.0 provisioning** — has its own RFC 7644 bearer-token surface at `/scim/v2/*`; events emitted by the IdP, not by Mobieus
- **Outbound xAPI bridge** — *is* itself a webhook-style emitter, just one whose destination is a vendor LRS, not a generic HTTP endpoint
- **Live sessions** (1.4.0) — the schedule + ICS + reminder emails fire from cron, not as outbound webhooks. If you want to mirror scheduled sessions, poll `GET /api/v1/learn/live-sessions` on a 5-minute cron until we expose `learn.live_session.scheduled` as an event in a future minor.