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": "jordan" }
}
}
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.
New webhook events (1.6.0)
Nine new events are now available in addition to the original community core events:
mobieusHelp
| Event | When |
|---|---|
ticket.created |
A new support ticket is opened |
ticket.replied |
An agent or requester adds a message |
ticket.status_changed |
Status changes (open, pending, resolved, closed) |
ticket.assigned |
Ticket is assigned to an agent or team |
mobieusLearn
| Event | When |
|---|---|
enrollment.created |
A learner enrolls in a course |
enrollment.completed |
A learner completes a course |
course.published |
A course is published for the first time |
mobieusKnow
| Event | When |
|---|---|
page.created |
A new wiki page is created and approved |
page.updated |
An approved edit is applied to a page |
Subscribe to these events the same way as any other — from Admin → Webhooks, pick the event types you want, or use ["*"] to receive all.
# 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": "jordan" }
}
}
```
## 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.
## New webhook events (1.6.0)
Nine new events are now available in addition to the original community core events:
### mobieusHelp
| Event | When |
|---|---|
| `ticket.created` | A new support ticket is opened |
| `ticket.replied` | An agent or requester adds a message |
| `ticket.status_changed` | Status changes (open, pending, resolved, closed) |
| `ticket.assigned` | Ticket is assigned to an agent or team |
### mobieusLearn
| Event | When |
|---|---|
| `enrollment.created` | A learner enrolls in a course |
| `enrollment.completed` | A learner completes a course |
| `course.published` | A course is published for the first time |
### mobieusKnow
| Event | When |
|---|---|
| `page.created` | A new wiki page is created and approved |
| `page.updated` | An approved edit is applied to a page |
Subscribe to these events the same way as any other — from **Admin → Webhooks**, pick the event types you want, or use `["*"]` to receive all.