# Step 6: Monitor & Operate ``` ASSISTIV_API_BASE = https://api.assistiv.ai ``` Three layers to monitor usage, reconcile costs, and keep the user's dashboard in sync: 1. **Outbound webhooks** — Assistiv pushes real-time events to a URL you own. Replaces polling. Register endpoints in the dashboard. Delivery includes HMAC signing, retries, and a replay UI. 2. **End-user self-service** — the user's own browser/app calls `/v1/me/*` with their `sk-eu_*` key to render balance and limits. 3. **Platform admin queries** — `/v1/platforms/{pid}/logs` for inference logs and `/budget/transactions` for the budget ledger. --- ## Outbound Webhooks Assistiv sends webhook notifications when budget state changes. Use them to: - Update your dashboard in real time when a user's balance drops - Alert a user before they run out (`budget.low_balance`) - Trigger "account suspended" flows in your product (`budget.suspended`) - Feed a finance system with transaction-level events ### Event types | Event | Fires when | Typical use | |---|---|---| | `budget.topped_up` | `POST /budget/topup` succeeds (including Stripe-triggered topups) | Update dashboard, send receipt email | | `budget.low_balance` | A debit crosses `low_balance_threshold` for the first time since the last reset/topup. **Edge-triggered, auto re-arms after a topup.** | Email the user a warning | | `budget.suspended` | `PATCH /budget { is_suspended: true }` flips the flag | Show "account paused" banner | | `budget.unsuspended` | `PATCH /budget { is_suspended: false }` flips the flag | Clear the banner | | `budget.debited` | Every successful debit. **Firehose, opt-in only.** | Ledger mirroring | `budget.low_balance` is **edge-triggered**: it fires exactly once on the debit that crosses the threshold. A topup that brings the balance back above re-arms it. No dedupe needed on your end. `budget.debited` is **off by default**. At LLM cadence (hundreds of requests per minute per user) this event produces high volume. Only enable it if you need per-call notifications. ### Registering an endpoint Go to **Dashboard → Webhooks**. Click "Add endpoint", paste your receiver URL, check the event types you want. Default-checked: `budget.topped_up`, `budget.low_balance`, `budget.suspended`, `budget.unsuspended`. Default-unchecked: `budget.debited`. Once saved, your endpoint's **signing secret** is shown in the table. Click "Reveal" to view it, then copy it into your receiver's environment (e.g. `ASSISTIV_WEBHOOK_SECRET`). You'll need this to verify webhook signatures. Click "Delivery logs" to see: - Every delivery attempt with request/response payloads - Manual replay for any failed message - Endpoint health (success rate, latency) ### Payload shape (stable v1) Every message has this envelope: ```json { "event_type": "budget.low_balance", "event_id": ":budget.low_balance", "api_version": "2026-04-11", "created_at": "2026-04-11T15:00:00Z", "data": { "platform_id": "uuid", "end_user_id": "uuid", "budget_id": "uuid", "transaction_id": "uuid", "type": "debit", "amount_usd": 5.00, "max_usd_after": 100.00, "used_usd_after": 95.00, "remaining_usd_after": 5.00, "reason": null, "metadata": {} } } ``` **Guarantees:** - Hand-picked fields only. Internal columns are never exposed. - `event_id` is stable per (transaction, event_type). Use it as your idempotency key on the receiver. - `api_version` bumps on breaking changes; new fields are added without bumping. **Ordering is not guaranteed.** Use `event_id` and `created_at` to reconcile. If you need strict ordering, query `GET /budget/transactions` with `?since=` instead. ### Signature verification Every webhook is signed with an HMAC secret unique to your endpoint. Find it in **Dashboard → Webhooks → Reveal** on the endpoint row. #### Node.js ```javascript import express from "express"; import crypto from "crypto"; const WEBHOOK_SECRET = process.env.ASSISTIV_WEBHOOK_SECRET; app.post( "/webhooks/assistiv", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["webhook-signature"]; const timestamp = req.headers["webhook-timestamp"]; const body = req.body.toString(); // Verify HMAC const toSign = `${req.headers["webhook-id"]}.${timestamp}.${body}`; const expected = crypto .createHmac("sha256", Buffer.from(WEBHOOK_SECRET.split("_")[1], "base64")) .update(toSign) .digest("base64"); const signatures = signature.split(" "); const valid = signatures.some(sig => { const sigValue = sig.split(",")[1]; return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(sigValue) ); }); if (!valid) return res.status(400).send("invalid signature"); const evt = JSON.parse(body); switch (evt.event_type) { case "budget.low_balance": notifyLowBalance(evt.data.end_user_id, evt.data.remaining_usd_after); break; case "budget.suspended": showSuspendedBanner(evt.data.end_user_id); break; case "budget.topped_up": refreshBalanceCache(evt.data.end_user_id); break; } res.sendStatus(200); } ); ``` #### Python / FastAPI ```python import hashlib, hmac, base64, os from fastapi import FastAPI, Request, HTTPException app = FastAPI() WEBHOOK_SECRET = os.environ["ASSISTIV_WEBHOOK_SECRET"] @app.post("/webhooks/assistiv") async def assistiv_webhook(request: Request): body = await request.body() msg_id = request.headers.get("webhook-id", "") timestamp = request.headers.get("webhook-timestamp", "") signature_header = request.headers.get("webhook-signature", "") secret_bytes = base64.b64decode(WEBHOOK_SECRET.split("_")[1]) to_sign = f"{msg_id}.{timestamp}.{body.decode()}".encode() expected = base64.b64encode( hmac.new(secret_bytes, to_sign, hashlib.sha256).digest() ).decode() signatures = signature_header.split(" ") valid = any( hmac.compare_digest(expected, sig.split(",")[1]) for sig in signatures if "," in sig ) if not valid: raise HTTPException(status_code=400, detail="invalid signature") evt = await request.json() match evt["event_type"]: case "budget.low_balance": await notify_low_balance(evt["data"]["end_user_id"]) case "budget.suspended": await show_suspended_banner(evt["data"]["end_user_id"]) case "budget.topped_up": await refresh_balance_cache(evt["data"]["end_user_id"]) return {"ok": True} ``` The signing secret is in your dashboard (Webhooks → Reveal). To rotate it, use the delivery logs portal. ### Delivery guarantees - **At-least-once.** Failed deliveries are retried with exponential backoff over ~24 hours. Your receiver must be idempotent — use `event_id` as the dedupe key. - **Low latency.** Expect events within 1 second of the mutating request under normal conditions; within 30 seconds under partial failure. ### Reconciling missed events If your receiver was down and events expired past the retry window, backfill from the ledger: ``` GET ASSISTIV_API_BASE/v1/platforms/{pid}/end-users/{euid}/budget/transactions?since=2026-04-10T00:00:00Z&limit=200 Authorization: Bearer sk-plat_... ``` Replay in `created_at` order. Every ledger row carries the same data the webhook payload carries, so your event handler can reuse the same code path. ### What to build with webhooks | Feature | Webhook approach | Old polling approach | |---|---|---| | Real-time balance | Subscribe to `topped_up` + `low_balance` | Poll `/me/budget` every 60s | | Low-balance email | Receive `budget.low_balance`, send email | Loop every 5 min checking all users | | "Account paused" UX | Receive `budget.suspended`, flip a flag | Poll `/me/budget` (returns 404 when suspended/disabled) | | Finance feed | Receive all events, pipe into your ledger | 4-hour reconcile job | | Reliability fallback | Nightly `GET /budget/transactions?since=` | (was the primary path) | --- ## End-User Self-Service Endpoints for the end user to query their own state. Auth: **end-user key only**. Returns 403 if called with a platform key. ### GET /v1/me/budget Get the end user's active budget. Response: ```json { "id": "uuid", "platform_id": "uuid", "end_user_id": "uuid", "max_usd": 10.00, "used_usd": 1.50, "remaining_usd": 8.50, "period": "monthly", "period_start": "2026-04-01T00:00:00Z", "auto_replenish": true, "is_active": true, "is_suspended": false } ``` Returns 404 if no active budget exists. Returns the row even when `is_suspended=true` so your UI can render an "account paused" state. If you're subscribed to budget webhooks, re-fetch on webhook arrival instead of polling. ### GET /v1/me/rate-limits Get the end user's effective rate limits. Response: ```json { "rpm_limit": 60, "tpm_limit": 100000, "rpd_limit": 10000, "resolution": "explicit" } ``` `resolution` is one of: - `"explicit"` — user has an override via `/end-users/{id}/rate-limits` - `"default"` — falling back to platform default - `"none"` — no limits configured ### GET /v1/me/mcp-config Returns per-app MCP URLs for the authenticated end user. See [Step 5 — MCP Tools](https://www.assistiv.ai/docs/integration/step-5-mcp-tools.txt) for full details. --- ## Logs Query the inference log for auditing and billing reconciliation. ### GET /v1/platforms/{platformId}/logs Auth: platform key. Query params: - `page` (default 1) - `limit` (default 20, max 100) - `model` (string) — Filter by model slug - `end_user_id` (string UUID) — Filter to one end user Response: ```json { "data": [ { "id": "uuid", "platform_id": "uuid", "end_user_id": "uuid", "request_type": "chat.completion", "provider_id": "uuid", "model_id": "uuid", "provider_model_id": "gpt-4o-2024-08-06", "prompt_tokens": 25, "completion_tokens": 8, "total_tokens": 33, "latency_ms": 420, "estimated_cost_usd": "0.000175", "status": "success", "error_message": null, "token_count_method": "reported", "billing_status": "billed", "metadata": {}, "created_at": "2026-04-08T10:30:00Z" } ], "total": 128, "page": 1, "limit": 20 } ``` ### Request types | `request_type` | Written by | Emitted per | |---|---|---| | `chat.completion` | inference backend | one per `/v1/chat/completions` call | | `response` | inference backend | one per agent-loop iteration within `/v1/responses` | | `agent` | inference backend | one per agent-model call (LangGraph path) | | `mcp_tool` | MCP service | one per executed MCP tool call (paid path only) | A single `/v1/responses` call with MCP tools can produce multiple rows. Typical for "do X with GitHub": 2x `response` rows (model decides + final answer) + 1x `mcp_tool` row = 3 rows for one user-facing request. **Analytics guidance:** - Don't count rows as "requests" — you'll inflate by 2-3x on MCP users. Group by timestamp clusters or request IDs instead. - Sum `estimated_cost_usd` across all request types to reconcile against the wallet delta. --- ## Common Pattern: Get-or-Create End User Idempotent signup — safe to call on every auth callback or retry: ```typescript async function ensureAssistivUser(yourUserId: string, displayName: string) { const res = await plat("/end-users", { method: "POST", body: JSON.stringify({ external_id: yourUserId, display_name: displayName }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error?.message ?? "failed"); return { assistivUserId: data.id, endUserKey: data.api_key.raw_key, wasNew: res.status === 201, }; } ``` `raw_key` is returned exactly once per POST. Store it in your database. --- ## Common Pattern: Cost Reconciliation Two sources for "this user spent $X this month": **A) Budget ledger (canonical).** `GET /budget/transactions` is the source of truth for every budget state change. ```typescript async function getUserSpendThisMonth(userId: string): Promise { const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString(); const res = await plat(`/end-users/${userId}/budget/transactions?since=${monthStart}&limit=200`); const { data } = await res.json(); return data .filter((row: any) => row.type === "debit") .reduce((sum: number, row: any) => sum + Number(row.amount_usd || 0), 0); } ``` **B) Inference logs (details).** `GET /logs` has per-call token counts, latency, model, and error messages. Use for debugging and per-model breakdowns. Both sources agree on total spend. The ledger is the one to trust for billing; the logs are the one to trust for "which model". **Best pattern for an always-up-to-date dashboard:** subscribe to webhook events and write them into your own DB. Dashboard queries hit your DB, no Assistiv round-trips on page load. Use the ledger endpoint for backfill and reconciliation. --- Next: [Step 7 — Skills (Optional, private beta)](https://www.assistiv.ai/docs/integration/step-7-skills.txt) Back to: [Integration Guide Index](https://www.assistiv.ai/llms-full.txt)