> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mnemom.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Notifications

> Create org-level webhook endpoints, verify signed deliveries, and handle retries — real-time HTTP POST notifications instead of polling.

Instead of polling the API, register a webhook endpoint and Mnemom POSTs events to it as they
happen — integrity checkpoints, drift alerts, Safe House verdicts, quota thresholds, card and
team changes, and billing events. See the [Webhook Event Catalog](/api-reference/webhook-events)
for the full list of event types and their payloads.

Every delivery is signed with HMAC-SHA256 so your server can verify authenticity before acting
on it.

## Create an endpoint

```bash theme={null}
curl -X POST https://api.mnemom.ai/v1/orgs/{org_id}/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/mnemom",
    "description": "Production alerts",
    "event_types": ["integrity.violation", "sideband.drift.fired", "sh.evaluation.block"]
  }'
```

Or with the CLI:

```bash theme={null}
mnemom webhooks create <org_id> \
  --url https://your-server.com/webhooks/mnemom \
  --events integrity.violation,sideband.drift.fired,sh.evaluation.block \
  --description "Production alerts"
```

The response includes the signing secret **exactly once**:

```json theme={null}
{
  "endpoint_id": "whe-a1b2c3d4",
  "url": "https://your-server.com/webhooks/mnemom",
  "signing_secret": "a1b2c3d4e5f6...",
  "event_types": ["integrity.violation", "sideband.drift.fired", "sh.evaluation.block"],
  "is_active": true
}
```

<Warning>
  Copy `signing_secret` immediately — it is not retrievable after creation. If you lose it, rotate
  it: `mnemom webhooks rotate-secret <org_id> <endpoint_id>` or `POST /v1/orgs/{org_id}/webhooks/{endpoint_id}/rotate-secret`.
</Warning>

Requirements:

* Up to **5 endpoints per organization**.
* `url` must be `https://`, with no username or password in it. Loopback, RFC-1918/link-local/CGNAT
  ranges, cloud metadata addresses, and internal-only names (`localhost`, single-label hosts,
  `.local`, `.internal`) are rejected, and so is a hostname that resolves to any of those addresses.
  The check runs at creation time and again immediately before every delivery. A delivery follows
  at most 3 redirects, and each redirect target goes through the same check.
* `event_types` defaults to none — you opt in explicitly per event type. Leave it as an empty
  list only if you intend to add types later via `PATCH`.

Send a test delivery to confirm connectivity before waiting on a real event:

```bash theme={null}
curl -X POST https://api.mnemom.ai/v1/orgs/{org_id}/webhooks/{endpoint_id}/test \
  -H "Authorization: Bearer $TOKEN"
# {"success": true, "status": 200, "latency_ms": 145, "error": null}
```

If the test can't reach your endpoint (DNS failure, connection refused, timeout, or a blocked
address or redirect), `success` is `false` and `error` is the generic `"destination unreachable"`.
The response doesn't say which of these happened, so check your endpoint's DNS, TLS, and firewall.
If your endpoint answers with an HTTP error, `status` carries its status code.

## Payload envelope

Every delivery uses the same envelope, whichever event type fired:

```json theme={null}
{
  "id": "evt-a1b2c3d4",
  "type": "integrity.violation",
  "created_at": "2026-02-17T14:30:00.000Z",
  "account_id": "ba-x1y2z3w4",
  "data": { "...": "event-specific fields — see the event catalog" }
}
```

| Field | Description |
| - | - |
| `id` | Unique event ID (`evt-xxxxxxxx`). Use it as an idempotency key. |
| `type` | One of the types in the [event catalog](/api-reference/webhook-events). |
| `created_at` | ISO 8601 timestamp. |
| `account_id` | The billing account the event belongs to. |
| `data` | Event-specific fields. |

## Signature verification

Every delivery carries three headers:

| Header | Description |
| - | - |
| `X-Webhook-Id` | Unique event ID (same as `payload.id`). |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the payload was signed. |
| `X-Webhook-Signature` | HMAC-SHA256 signature, format `v1={hex}`. |

The signature is computed over the string `{timestamp}.{raw_body}` using your endpoint's signing
secret as the HMAC key — the same construction as [Stripe's webhook
signing](https://docs.stripe.com/webhooks/signatures). Always verify against the **raw** request
body, not a re-serialized JSON object, and use a constant-time comparison.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from 'crypto';

  function verifyWebhookSignature(req, signingSecret) {
    const timestamp = req.headers['x-webhook-timestamp'];
    const signature = req.headers['x-webhook-signature']; // "v1=<hex>"
    const rawBody = req.rawBody; // must be the raw string, not parsed JSON

    if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
      throw new Error('Timestamp too old — possible replay');
    }

    const expected = 'v1=' + createHmac('sha256', signingSecret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      throw new Error('Invalid signature');
    }
    return JSON.parse(rawBody);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time, json

  def verify_webhook_signature(request, signing_secret):
      timestamp = request.headers['X-Webhook-Timestamp']
      signature = request.headers['X-Webhook-Signature']  # "v1=<hex>"
      raw_body = request.get_data(as_text=True)

      if abs(time.time() - int(timestamp)) > 300:
          raise ValueError('Timestamp too old — possible replay')

      expected = 'v1=' + hmac.new(
          signing_secret.encode(), f'{timestamp}.{raw_body}'.encode(), hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(signature, expected):
          raise ValueError('Invalid signature')
      return json.loads(raw_body)
  ```
</CodeGroup>

## Delivery, retries & auto-disable

Mnemom attempts inline delivery immediately when an event fires, then falls back to a
background retry loop on failure. Retries follow a fixed backoff schedule and stop after **6
total attempts**:

| Attempt | Delay after previous |
| - | - |
| 1 (initial) | — |
| 2 | 10s |
| 3 | 30s |
| 4 | 2m |
| 5 | 10m |
| 6 | 1h |

| Response | Behavior |
| - | - |
| **2xx** | Delivered. |
| **429** | Retried with at least 60s backoff. |
| **4xx** (other) | Permanent failure — not retried. |
| **5xx** or timeout | Retried per the schedule above. |

Your endpoint has **30 seconds** to respond. If processing takes longer, return `200`
immediately and process asynchronously.

If an endpoint accumulates **100 consecutive delivery failures**, it is automatically disabled
(`is_active: false`). Re-enable it once the issue is fixed:

```bash theme={null}
mnemom webhooks update <org_id> <endpoint_id> --active true
```

Re-enabling resets the failure counter.

### Idempotency & ordering

Use `id` from the payload envelope as an idempotency key — the same event can be delivered more
than once (retries, manual redelivery, or replay). Deliveries are best-effort ordered by
creation time; use `created_at` if your handler needs strict ordering.

## Delivery log, health, and replay

```bash theme={null}
# Delivery log (most recent first)
mnemom webhooks list-deliveries <org_id> --endpoint <endpoint_id> --limit 50
# or: GET /v1/orgs/{org_id}/webhooks/deliveries?endpoint_id=...

# Retry one failed delivery
mnemom webhooks redeliver <org_id> <delivery_id>
# or: POST /v1/orgs/{org_id}/webhooks/deliveries/{delivery_id}/redeliver

# Re-fan-out a historical event to its subscribed endpoints (or a subset)
mnemom webhooks replay <org_id> <event_id> [--endpoint <endpoint_id>]
# or: POST /v1/orgs/{org_id}/webhooks/events/{event_id}/replay  (requires an Idempotency-Key header)

# Aggregate health: active/disabled endpoints, 24h success rate, top event types
GET /v1/orgs/{org_id}/webhooks/health
```

For live debugging without registering an endpoint, stream deliveries directly:

```bash theme={null}
mnemom listen <org_id> --filter integrity.violation,sh.evaluation.block
```

`mnemom listen` (and the underlying `GET /v1/orgs/{org_id}/webhooks/listen` SSE stream) mirrors
events to your terminal in near-real-time — Stripe CLI-equivalent ergonomics for local
development. Pass `--forward-to` with `--secret` to re-sign and forward events to a local server.

## CLI reference

```bash theme={null}
mnemom webhooks list <org_id>
mnemom webhooks get <org_id> <endpoint_id>
mnemom webhooks create <org_id> --url <url> --events <list> --description <text>
mnemom webhooks update <org_id> <endpoint_id> [--url] [--events] [--description] [--active true|false]
mnemom webhooks delete <org_id> <endpoint_id>
mnemom webhooks rotate-secret <org_id> <endpoint_id>
mnemom webhooks trigger <org_id> <endpoint_id>
mnemom webhooks list-deliveries <org_id> [--endpoint] [--limit] [--offset]
mnemom webhooks redeliver <org_id> <delivery_id>
mnemom webhooks replay <org_id> <event_id> [--endpoint ...]
mnemom listen <org_id> [--forward-to <url> --secret <hex>] [--filter <list>] [--since <event_id>]
```

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signature mismatch">
    * Verify against the **raw** request body, not re-serialized JSON.
    * Confirm the signing secret — it's shown once at creation; rotate if lost.
    * `X-Webhook-Timestamp` is Unix **seconds**, not milliseconds.
  </Accordion>

  <Accordion title="HTTPS required">
    Webhook endpoint URLs must be `https://`. HTTP URLs are rejected at creation time. For local
    development, tunnel with [ngrok](https://ngrok.com) or [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
  </Accordion>

  <Accordion title="Endpoint auto-disabled">
    After 100 consecutive failures the endpoint is disabled. Fix the underlying issue, then
    `mnemom webhooks update <org_id> <endpoint_id> --active true` — this resets the failure counter.
  </Accordion>

  <Accordion title="Events not firing">
    * Confirm the endpoint is active (`is_active: true`).
    * Confirm the event type is in the endpoint's `event_types` (empty means none — not all).
    * Check `GET /v1/orgs/{org_id}/webhooks/deliveries` for failed attempts.
  </Accordion>
</AccordionGroup>

## API reference

| Endpoint | Description |
| - | - |
| `POST /orgs/{org_id}/webhooks` | Create webhook endpoint |
| `GET /orgs/{org_id}/webhooks` | List webhook endpoints |
| `GET /orgs/{org_id}/webhooks/{endpoint_id}` | Get endpoint details |
| `PATCH /orgs/{org_id}/webhooks/{endpoint_id}` | Update endpoint |
| `DELETE /orgs/{org_id}/webhooks/{endpoint_id}` | Delete endpoint |
| `POST /orgs/{org_id}/webhooks/{endpoint_id}/rotate-secret` | Rotate signing secret |
| `POST /orgs/{org_id}/webhooks/{endpoint_id}/test` | Send test delivery |
| `GET /orgs/{org_id}/webhooks/deliveries` | Delivery log |
| `POST /orgs/{org_id}/webhooks/deliveries/{delivery_id}/redeliver` | Redeliver failed event |
| `POST /orgs/{org_id}/webhooks/events/{event_id}/replay` | Replay an event to eligible endpoints |
| `GET /orgs/{org_id}/webhooks/health` | Aggregate delivery health |
| `GET /orgs/{org_id}/webhooks/listen` | Server-Sent Events stream of deliveries |

## See also

* [Webhook Event Catalog](/api-reference/webhook-events) — every event type and its payload.
* [Safe House Threat Model](/guides/safe-house-threat-model) — what `sh.*` verdicts mean.
* [Card-Change Notifications](/guides/card-change-notifications) — a separate, per-agent
  webhook/SSE mechanism specifically for canonical card changes (its own signing convention).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.