Webhooks
Webhooks
Webhooks are CIR's outbound HTTP interface. When events occur on the cloud (a CIR signal arrives, training data is ingested, a usage limit is hit), registered HTTPS endpoints receive HMAC-SHA256 signed notifications. You use webhooks to close the automation loop — external events trigger local recipe execution.
How it works
- You register a webhook — specifying the event type, your HTTPS endpoint, and a signing secret
- When the event occurs, the cloud delivers a signed payload to your endpoint
- Your consumer verifies the HMAC signature and dispatches a recipe with the payload as variables
Available events
| Event | Fires when | Status |
|---|---|---|
cir.capture |
A CIR signal is pushed to the cloud | Live |
training.ingested |
Training data is submitted | Live |
usage.limit_reached |
An API usage limit is exhausted | Live |
sequence.completed |
A sequence finishes | Planned |
temporal.fired |
A temporal fires | Planned |
adapter.published |
An adapter is published | Planned |
Register a webhook
Via CLI
mentu webhook register \
--event cir.capture \
--url https://myapp.com/hook \
--secret "your-secret-at-least-16-chars"The CLI reads your API key from vault (scope: api). Make sure you've set it:
mentu vault set api-key <your-key> --scope apiVia script SDK
const hook = mentu.webhook.register(
'cir.capture',
'https://myapp.com/hook',
'your-secret-at-least-16-chars'
);
console.log('Registered:', hook.id);Registration constraints
| Constraint | Requirement |
|---|---|
| Event | Must be in the allowed events list |
| URL | Must use HTTPS |
| Secret | Minimum 16 characters |
| Auth | Valid API key required (Bearer token) |
Manage webhooks
# List all registered webhooks
mentu webhook list
# Test delivery (sends a test payload to your endpoint)
mentu webhook test <webhook-id>
# Delete a webhook
mentu webhook delete <webhook-id>Or from a script:
const hooks = mentu.webhook.list();
for (const h of hooks) {
console.log(`${h.id}: ${h.event} → ${h.url} (active: ${h.active})`);
}
// Test delivery
const result = mentu.webhook.test(hooks[0].id);
console.log('Delivered:', result.delivered, 'Code:', result.response_code);
// Clean up
mentu.webhook.delete(hooks[0].id);Delivery format
Every delivery includes these headers:
| Header | Value | Purpose |
|---|---|---|
Content-Type |
application/json |
Payload format |
X-Mentu-Signature |
Hex-encoded HMAC-SHA256 | Verify payload integrity |
X-Mentu-Event |
Event type string | Identify event without parsing body |
X-Mentu-Delivery |
whd_{uuid} |
Unique delivery ID for deduplication |
Payload structure:
{
"event": "cir.capture",
"timestamp": "2026-04-04T12:00:00Z",
"source": "cir_push"
}event and timestamp are always present. Additional fields depend on the emission source (e.g., usage.limit_reached includes limit, reset_at, and route).
Verify HMAC signatures
Every delivery is signed with your webhook's secret using HMAC-SHA256. Consumers must verify the X-Mentu-Signature header against the raw request body.
Node.js / Bun
import { createHmac, timingSafeEqual } from 'crypto';
function verify(body: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret)
.update(body)
.digest('hex');
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Cloudflare Worker (Web Crypto)
async function verify(body: string, signature: string, secret: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const expected = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Constant-time comparison
if (expected.length !== signature.length) return false;
let mismatch = 0;
for (let i = 0; i < expected.length; i++) {
mismatch |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
}
return mismatch === 0;
}Always use constant-time comparison. Never use === for signature verification — it is vulnerable to timing attacks.
Set up a local receiver
A ready-made Bun receiver script is included at ~/.mentu/scripts/webhook-receiver.ts. It verifies HMAC signatures and dispatches recipes automatically.
Start the receiver
WEBHOOK_SECRET=your-secret RECIPE=on-webhook-event \
bun run ~/.mentu/scripts/webhook-receiver.ts| Env Var | Required | Default | Purpose |
|---|---|---|---|
WEBHOOK_SECRET |
Yes | — | HMAC signing secret (must match registered webhook) |
RECIPE |
Yes | — | Recipe name to dispatch on verified delivery |
PORT |
No | 9876 |
Listen port |
MENTU_BIN |
No | mentu |
Path to mentu binary |
What it does
- Receives POST requests on the configured port
- Verifies
X-Mentu-Signatureusing HMAC-SHA256 +timingSafeEqual - Extracts payload fields into uppercase
--varkeys (source→--var SOURCE=cir_push) - Spawns
mentu sequence <recipe> --var EVENT=... --var SOURCE=...
HTTP responses: 200 (dispatched), 401 (bad signature), 400 (bad JSON), 405 (wrong method).
Expose to the internet
The local receiver listens on localhost. To receive deliveries from api.mentu.ai, expose it via a tunnel:
# Cloudflare Tunnel (recommended)
cloudflared tunnel --url http://localhost:9876
# Or ngrok
ngrok http 9876Register your webhook with the tunnel URL.
Deploy an edge receiver
For production, deploy a Cloudflare Worker that verifies signatures at the edge and optionally forwards to your local receiver.
cp -r ~/.mentu/templates/webhook-worker my-webhook-worker
cd my-webhook-worker
npx wrangler secret put WEBHOOK_SECRET
npx wrangler deployIf you set a DISPATCH_URL secret, the worker forwards verified payloads to that URL (e.g., your local Bun receiver behind a tunnel). Otherwise it logs and returns 200.
Build a triggered recipe
Webhook payloads become recipe variables via Layer 3 injection. The receiver maps payload fields to --var KEY=VALUE — and SequenceRunner picks them up at the highest priority layer.
Example recipe
Save as ~/.mentu/recipes/on-webhook-event.json:
{
"name": "on-webhook-event",
"type": "formula",
"model": "claude-sonnet-4-20250514",
"env": {
"EVENT": "unknown",
"SOURCE": "webhook",
"ROUTE": ""
},
"steps": [
{
"label": "react-to-event",
"prompt_file": "prompts/on-webhook-event/react.md"
}
]
}The env block provides defaults. Webhook vars override them at Layer 3. Your prompt can reference $EVENT, $SOURCE, $ROUTE.
Variable resolution order
Layer 1: recipe env block (static defaults) — lowest priority
Layer 2: process environment (non-MENTU_ keys)
Layer 3: CLI --var / webhook / trigger vars — highest priorityTest without a webhook
mentu sequence on-webhook-event \
--var EVENT=cir.capture \
--var SOURCE=test \
--var ROUTE=/cir/pushThis exercises the same code path the receiver uses — the recipe doesn't know whether vars came from a webhook or the CLI.
Retry behavior
Failed deliveries are retried with exponential backoff:
| Attempt | Delay | Timeout |
|---|---|---|
| 1 | Immediate | 10s |
| 2 | 5s | 10s |
| 3 | 30s | 10s |
After 3 failed attempts, the delivery is recorded as "failed". Success on any attempt stops retrying.
End-to-end example
Put it all together — subscribe to CIR capture events and auto-investigate:
# 1. Start the local receiver
WEBHOOK_SECRET=my-secret-at-least-16ch RECIPE=on-webhook-event \
bun run ~/.mentu/scripts/webhook-receiver.ts &
# 2. Expose via tunnel
cloudflared tunnel --url http://localhost:9876
# → https://abc123.trycloudflare.com
# 3. Register the webhook
mentu webhook register \
--event cir.capture \
--url https://abc123.trycloudflare.com \
--secret "my-secret-at-least-16ch"
# 4. Test delivery
mentu webhook test <webhook-id>
# → Delivered: true, Code: 200
# 5. Now any CIR signal pushed to the cloud triggers your recipe automatically