Mentu

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

Cloud: CIR event occurs on api.mentu.ai
HMAC-signed POST
Consumer: verify signature, extract payload
dispatch
Local: mentu sequence <recipe> --var EVENT=... --var SOURCE=...
  1. You register a webhook — specifying the event type, your HTTPS endpoint, and a signing secret
  2. When the event occurs, the cloud delivers a signed payload to your endpoint
  3. 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 api

Via 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

  1. Receives POST requests on the configured port
  2. Verifies X-Mentu-Signature using HMAC-SHA256 + timingSafeEqual
  3. Extracts payload fields into uppercase --var keys (source--var SOURCE=cir_push)
  4. 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 9876

Register 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 deploy

If 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 priority

Test without a webhook

mentu sequence on-webhook-event \
  --var EVENT=cir.capture \
  --var SOURCE=test \
  --var ROUTE=/cir/push

This 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
© 2026 Mentu.