# NETICS Developer Integration Guide This guide covers embedding the NETICS agent on your website and integrating your own systems with NETICS via webhooks and API keys. --- ## 1. Overview The NETICS agent SDK is a single JavaScript file that registers a standard [web component](https://developer.mozilla.org/docs/Web/API/Web_components), ``. It renders a launcher in the corner of your page and gives your visitors: - **Grounded chat** — answers streamed from your business's approved knowledge base and published business profile. The agent does not answer from the open internet. - **Realtime voice** — a spoken conversation with the same grounded knowledge, provided by NETICS. **Security model in one paragraph:** the only identifier you put in your page is the agent's *public ID*, which grants no access by itself. When a visitor opens the widget, the SDK exchanges that public ID for a short-lived widget session — but only if your page's origin is on the agent's allowed-origins list. Everything after that is authorised by the session's bearer token, which is pinned to the origin that created it. Your NETICS dashboard credentials, cookies, and API keys are never present in the browser page that embeds the widget. --- ## 2. Install Add two lines to your page: ```html ``` - **`agent-id`** — the agent's public ID, found in **AI Studio** in the NETICS dashboard. Public IDs look like `agent_…` and are **safe to expose**: they grant no dashboard access, and the widget only ever receives an expiring, origin-checked session. - **`api-base`** — the NETICS server URL. If omitted, the SDK uses the origin the script was loaded from. The element also accepts optional `primary-color`, `position`, `title`, and `placeholder` attributes that override the appearance configured in AI Studio. Because `` is a standard custom element, the same two lines work in React, Next.js, Vue, or any other framework — render the tag as-is. A complete standalone example lives at [`docs/sdk-example.html`](./sdk-example.html). --- ## 3. Allowed origins Each agent has an allowed-origins list, managed in AI Studio. - Matching is **exact origin equality**: scheme + host + port. `https://shop.example.com` does not cover `https://www.example.com`, `http://shop.example.com`, or `https://shop.example.com:8443`. Default ports (80 for `http`, 443 for `https`) are normalised away. - Entries must be origins only — a path, query string, fragment, or embedded credentials is rejected when you save. - An agent can allow at most **30 origins**. - A page on an origin that is not on the list receives **HTTP 403** when the widget tries to create a session ("This website is not allowed to use the agent"), and the widget shows an error instead of connecting. Requests without a browser `Origin` header are also rejected with 403. --- ## 4. How sessions work When a visitor first opens the widget, the SDK calls: ``` POST {api-base}/api/embed/v1/agents/{agent-id}/sessions ``` and receives a **widget session**: a session ID, a bearer token, and an expiry. Every subsequent widget request carries `Authorization: Bearer `. Properties of a widget session: - **Per visitor.** The SDK stores the session (and a random visitor ID) in the browser's `localStorage` and resumes it on return visits until it expires. - **Origin-pinned.** The session records the origin that created it. Requests presenting the token from any other origin are rejected with 403. - **24-hour expiry.** After that, the SDK transparently creates a new session. - **100 messages per session.** Past the cap, the message endpoint returns HTTP 429 ("This conversation has reached its message limit"). - **60 new sessions per IP per hour** for each agent. Past that, session creation returns HTTP 429. The session is revalidated on every request: if the agent is disabled, the origin is removed from the allowed list, or the organisation is deactivated, the session stops working immediately. The NETICS **dashboard session and cookies are never involved**. The widget uses only its own bearer token; there is nothing a malicious page could ride on via cookies, and nothing widget-side can reach dashboard endpoints. Messages are sent to `POST /api/embed/v1/sessions/{session_id}/messages` with `{"question": "..."}` (1–4000 characters) and answered as a Server-Sent Events stream; the widget renders `message.delta` text as it arrives and finishes on `message.completed`. --- ## 5. Voice Realtime voice is **provided by NETICS** — it appears in the dashboard's Integrations page as "NETICS Voice" and requires no account or credentials on your side. - When the visitor starts a voice conversation, the widget asks the NETICS backend for a short-lived voice token (`POST /api/embed/v1/sessions/{session_id}/voice-token`) and opens a WebRTC session. The voice provider's API key never leaves the NETICS server. - The browser will request **microphone permission**, which requires a secure context — in production your page must be served over **HTTPS**. - The voice the agent speaks with is chosen in **Voice Settings** in the dashboard; if none is chosen, the platform's default voice is used. - Voice answers are grounded exactly like chat: the realtime agent calls back into the same knowledge-grounded message endpoint to produce its answers. - Each voice conversation is recorded as a call in the **Live Calls** page, with a turn-by-turn transcript, duration, and outcome. Up to 400 transcript turns are stored per call. - If realtime voice is not configured for the workspace, or the browser cannot do WebRTC audio, the widget falls back to the browser's built-in speech recognition and synthesis (Web Speech API) where available; if neither is supported, the voice button is hidden. --- ## 6. Widget DOM events The `` element dispatches `CustomEvent`s (with `bubbles: true` and `composed: true`, so they can be observed on the element or on `document`). The payload is on `event.detail`. | Event | Fired when | `detail` | |---|---|---| | `netics-open` | The panel is opened | `{}` | | `netics-close` | The panel is closed | `{}` | | `netics-ready` | A session is established | `{ agentId, sessionId }` | | `netics-message` | An assistant answer finishes streaming | `{ role: "assistant", content }` | | `netics-transcript` | A voice utterance (either side) is transcribed | `{ role: "user" \| "assistant", content }` | | `netics-voice-start` | The visitor starts voice mode | `{ agentId, sessionId, provider }` | | `netics-voice-connected` | The realtime voice session connects | `{ conversationId, provider: "elevenlabs" }` | | `netics-voice-speaking` | Browser-voice fallback starts speaking an answer | `{ content }` | | `netics-voice-end` | Voice mode ends | `{ reason }` (may be empty when the visitor ends it) | | `netics-error` | Something failed (message, voice, or connection) | `{ message }` | Example: ```html ``` --- ## 7. Webhooks Webhooks push events from NETICS to your systems. Manage them in **API & Webhooks** in the dashboard: set a URL, pick events, and NETICS generates a signing secret (`whsec_…`) that is **shown once** at creation — only a last-four-characters hint is visible afterwards. There is no secret rotation endpoint: to rotate, **delete the webhook and create a new one**. In production, webhook URLs must resolve to a public address — URLs pointing at private, loopback, or link-local ranges are rejected when you save. ### 7.1 Event catalogue All 17 events, in their 7 groups: **Conversations** | Event | Description | |---|---| | `conversation.created` | A customer or team member starts a conversation | | `conversation.no_answer` | The AI couldn't answer from approved knowledge | | `conversation.feedback` | Someone rates an answer helpful or unhelpful | **Calls** | Event | Description | |---|---| | `call.started` | A voice call begins | | `call.completed` | A voice call ends, with duration and outcome | **Orders** | Event | Description | |---|---| | `order.created` | A new order lands | | `order.status_changed` | An order moves through the pipeline (from → to) | | `order.payment_changed` | An order's payment state changes, e.g. pending → paid | **Bookings** | Event | Description | |---|---| | `booking.created` | A reservation is made | | `booking.status_changed` | Confirmed, cancelled, checked in or no-show (from → to) | **Customers** | Event | Description | |---|---| | `customer.created` | A new customer record is created | | `customer.archived` | A customer record is archived | **Knowledge** | Event | Description | |---|---| | `document.ready` | A document finished processing and is answerable | | `document.failed` | A document could not be processed | | `document.deleted` | A document is removed from the knowledge base | **Workspace** | Event | Description | |---|---| | `member.joined` | An invited teammate accepts and joins | | `profile.published` | A new business profile version goes live for the AI | ### 7.2 Envelope Every delivery is a JSON POST with this shape: ```json { "id": "9f2b6d1e-…", "event": "order.created", "created_at": "2026-08-01T09:30:00+00:00", "data": { } } ``` `id` is the delivery's unique ID (use it for idempotency — retries reuse it), `event` is the event name, and `data` is the event payload. ### 7.3 Example payloads (`data`) ```jsonc // conversation.created { "conversation_id": "…", "channel": "embed" } // conversation.no_answer (question truncated to 500 chars) { "conversation_id": "…", "channel": "embed", "question": "Do you ship to Abuja?" } // conversation.feedback { "conversation_id": "…", "message_id": "…", "rating": "helpful" } // call.started { "call_id": "…", "conversation_id": "…", "channel": "web_voice" } // call.completed { "call_id": "…", "conversation_id": "…", "channel": "web_voice", "duration_seconds": 84, "resolved": true } // order.created { "order_id": "…", "reference": "…", "status": "new", "total": 15000.0, "channel": "…" } // order.status_changed { "order_id": "…", "reference": "…", "from": "new", "to": "confirmed", "total": 15000.0 } // order.payment_changed { "order_id": "…", "reference": "…", "from": "pending", "to": "paid", "total": 15000.0 } // booking.created { "booking_id": "…", "reference": "…", "service": "…", "starts_at": "2026-08-02T18:00:00+00:00", "party_size": 4 } // booking.status_changed { "booking_id": "…", "reference": "…", "from": "pending", "to": "confirmed" } // customer.created / customer.archived { "customer_id": "…", "name": "…" } // document.ready { "document_id": "…", "title": "…", "chunks": 42 } // document.failed / document.deleted { "document_id": "…", "title": "…" } // member.joined { "user_id": "…", "email": "…", "role": "member" } // profile.published { "version_id": "…", "version_number": 3 } ``` ### 7.4 Headers and signature Each delivery carries: ``` Content-Type: application/json User-Agent: NETICS-Webhooks/1.0 X-Netics-Event: X-Netics-Signature: t=,v1= ``` The signature is HMAC-SHA256, keyed with your `whsec_…` secret, over the string `"."`. The timestamp inside the signed message is what lets you reject replayed deliveries. **Verify against the raw request body bytes** exactly as received — do not parse and re-serialise the JSON first. Node.js (Express): ```js const crypto = require("crypto"); const express = require("express"); const app = express(); const SECRET = process.env.NETICS_WEBHOOK_SECRET; // whsec_… const TOLERANCE_SECONDS = 300; // ~5 minutes function verifyNeticsSignature(rawBody, header) { const parts = Object.fromEntries( (header || "").split(",").map((p) => p.split("=", 2)) ); const timestamp = Number(parts.t); if (!Number.isFinite(timestamp)) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", SECRET) .update(`${parts.t}.${rawBody}`) .digest("hex"); const given = Buffer.from(parts.v1 || "", "utf8"); const wanted = Buffer.from(expected, "utf8"); return given.length === wanted.length && crypto.timingSafeEqual(given, wanted); } // Raw body, not express.json() — the signature covers the exact bytes. app.post("/netics/webhook", express.raw({ type: "application/json" }), (req, res) => { if (!verifyNeticsSignature(req.body.toString("utf8"), req.get("X-Netics-Signature"))) { return res.status(400).send("bad signature"); } const delivery = JSON.parse(req.body.toString("utf8")); console.log(delivery.event, delivery.data); res.sendStatus(200); // any 2xx counts as delivered }); ``` Python: ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 # ~5 minutes def verify_netics_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item) try: timestamp = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False message = f"{parts['t']}.".encode() + raw_body expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) ``` ### 7.5 Delivery behaviour - **Timeout:** 10 seconds per attempt. Redirects are not followed. - **Success:** any **2xx** response. Anything else — including redirects — counts as a failure. - **Retries:** up to **5 attempts** per delivery, with backoff of **30 s → 2 min → 10 min → 1 h** between attempts. - **Auto-disable:** a delivery that exhausts all 5 attempts is a terminal failure. After **5 consecutive terminal failures** the endpoint is disabled automatically and the workspace receives a high-priority notification. A successful delivery resets the counter, and re-enabling the webhook in the dashboard starts it from zero again. - **Delivery log:** every attempt's outcome (status code, error, attempt count) is visible in the dashboard's deliveries feed. - **Send test:** the "Send test" button delivers a real, signed **`ping`** event through the same worker path as production events, with payload `{"message": "NETICS test event", "webhook_id": "…"}`. The webhook must be enabled to test it. --- ## 8. API keys API keys are created, rotated, and revoked in **API & Webhooks** (admin only). - Keys look like `nk_…`. The full key is **shown exactly once** at creation; only its prefix is stored and displayed afterwards. Keys are **hashed at rest** and cannot be recovered. - **Rotate** revokes the old key and mints a replacement with the same name and scopes in one step — the old key stops working immediately. - **Revoke** disables a key permanently. - Keys carry a **scopes** field (up to 20 scopes) recorded at creation. A key with **no scopes holds every scope** ("all" in the dashboard); a key with an explicit list holds exactly that list. - Every request stamps the key's **last used** time, shown in the dashboard. ### 8.1 The NETICS API (`/api/public/v1`) The server-to-server API these keys authorize. Base URL `https:///api/public/v1`; every request carries `Authorization: Bearer nk_...`. The key is **server-side only** — never in a web page, mobile binary, or repository. Website chat needs no key (that is the widget, protected by origin). Errors are JSON `{"detail": "..."}` with conventional status codes: 401 (missing/revoked/unknown key — indistinguishable on purpose), 403 (valid key, missing scope, or a dashboard-only action), 404 (no such record in *this* workspace), 422 (validation), 429 (rate limit; honour `Retry-After`), 502 (AI answer failed; retry). **Ask** — scope `ask`: - `POST /ask` — `{question (1–4000 chars), conversation_id?, audience?}` → `{conversation_id, message_id, answer, refused_for_lack_of_evidence, audience, citations, usage}`. `audience` defaults to `"customer"` (answers only from customer-visible knowledge, safe to relay to the public); `"team"` answers from everything and returns citations — internal tools only. Pass `conversation_id` back to continue a conversation. `refused_for_lack_of_evidence: true` means "ask a human", not an error. Answers may contain `[1]`-style citation markers; strip them for display. **Customers** — scopes `read:customers` / `write:customers`: - `GET /customers` — newest first; `q` matches name/email/phone; `include_archived`, `limit` (≤500), `offset`. - `POST /customers` — only `name` required. Emits `customer.created`. - `GET /customers/{id}` — includes `total_orders` and `total_bookings`. - `PATCH /customers/{id}` — send only changing fields; `loyalty_tier` and `loyalty_points` writable. No delete endpoint — archiving stays in the dashboard. **Orders** — scopes `read:orders` / `write:orders`: - `GET /orders` (`?status=`), `GET /orders/{id}`. - `POST /orders` — `items` required; the **total is computed server-side** from item rows, never trusted from the client. Items that name a `product_id` are **priced from the workspace catalogue** — the caller's `unit_price` is ignored for catalogue lines, and an unknown or inactive `product_id` is rejected with 422. Custom lines (no `product_id`) keep the caller's price. The AI can only take orders when the catalogue (Business → Products in the dashboard) has at least one active, in-stock product. Emits `order.created`. - `PATCH /orders/{id}` — `status` (pending → confirmed → preparing → ready → out_for_delivery → delivered, or cancelled), `payment_state` (pending, paid, failed — **refunded is rejected with 403**: refunds stay behind a human admin in the dashboard), `payment_method`, `delivery_address`, `notes`, `scheduled_for`. Status changes emit `order.status_changed`; payment changes emit `order.payment_changed`. **Bookings** — scopes `read:bookings` / `write:bookings`: - `GET /bookings` (`?status=`, `starts_after`, `starts_before` ISO 8601), `GET /bookings/{id}`. - `POST /bookings` — `starts_at` and `ends_at` required (end after start). Emits `booking.created`. - `PATCH /bookings/{id}` — `status` moves through pending, confirmed, checked_in, completed, cancelled, no_show, waitlist; changes emit `booking.status_changed`. **Avoiding webhook loops:** API writes emit the same webhooks as dashboard edits. Records created through the API carry `"channel": "api"` (AI-taken ones carry `"embed"`, staff entries `"workspace"`) — check it so your consumer skips events its own writes caused. --- ### 8.2 CLI and MCP server (device sign-in) `netics` (the command-line tool) and `@netics/mcp` (the MCP server for AI assistants) sit on this same API, and neither asks anyone to paste a key. `netics login` runs a browser device authorization: 1. The tool starts a loopback server on `127.0.0.1` and opens `/authorize/cli` with a PKCE `S256` challenge, its loopback `redirect_uri`, `state`, and a device name. 2. The consent page (signed-in session, owner or admin only) records a hashed one-time code with a 5 minute TTL and returns it. `redirect_uri` must be a loopback address. 3. The page redirects to `http://127.0.0.1:/callback?code=…&state=…`. 4. The tool posts the code and its verifier to `/api/public/v1/cli/token` and receives an ordinary `nk_` key named `NETICS CLI on `, saved to `~/.netics/config.json`. The result is a normal API key: it appears in **API & Webhooks**, records its last-used time, and is revoked there or by `netics logout` (`DELETE /api/public/v1/auth/key`). `GET /api/public/v1/auth/whoami` returns the workspace and key behind a key. Credential resolution in every tool: the `--key` flag, then `NETICS_API_KEY`, then the saved sign-in. CI and unattended servers should use a scoped key rather than a device sign-in. --- ## 9. Rate limits | Surface | Limit | |---|---| | Widget sessions | 60 new sessions per IP per agent per hour | | Widget messages | 100 messages per session | | Widget session lifetime | 24 hours | | Widget question length | 4,000 characters | | Voice transcript | 400 turns recorded per call (further turns dropped) | | Allowed origins | 30 per agent | | Webhook delivery timeout | 10 seconds per attempt | | Webhook attempts | 5 per delivery | | Webhook auto-disable | after 5 consecutive terminally failed deliveries | | API requests | 120 per key per minute (429 + `Retry-After: 60`) | | API key scopes | 20 per key | | Voice calls | 10 realtime voice calls per widget session | Limits over the cap return **HTTP 429** (widget sessions, messages, voice calls, API requests) or are silently truncated (voice transcript turns). --- ## 10. Not yet available Stated plainly so you can plan around it: - **Phone and WhatsApp channels** — NETICS Phone and WhatsApp via NETICS are provisioned **on request by NETICS**, not self-serve. You request setup from the Integrations page and an operator provisions the channel. - **Inbox replies** — replying to customer conversations from the dashboard is not available yet; the AI handles customer conversations. - **Payment events** — there are no `payment.*` webhook events yet; they will arrive together with the payments integration, not before. --- ## 11. Troubleshooting **The widget does not appear or shows an error** - Check that your page's exact origin (scheme + host + port) is on the agent's allowed-origins list in AI Studio. A disallowed origin gets HTTP 403. - Check that the agent is **enabled** — a disabled agent returns 404 to session creation. - Check `api-base` points at the NETICS server and that `/embed/netics-agent.js` loads from it (watch the browser's network tab). - `agent-id` must be set; without it the launcher renders disabled with a "Missing agent-id" message. **The voice button is missing** - The widget hides the voice button when the browser supports neither WebRTC microphone capture (`navigator.mediaDevices.getUserMedia`) nor the Web Speech API. Try Chrome or Edge. - Microphone capture requires a secure context: serve your page over HTTPS in production. - If NETICS realtime voice is not configured for the workspace, the widget falls back to browser voice where supported; if the realtime connection fails mid-session it also falls back, announcing it in the widget. **Webhook signature verification fails** - Verify against the **raw request body bytes**, not JSON you parsed and re-serialised — key order and whitespace differences break the HMAC. NETICS sends compact JSON (no spaces after `,` or `:`). - The signed message is `"."` using the `t` from the `X-Netics-Signature` header, not your own clock. - Confirm you are using the current `whsec_…` secret — if the webhook was deleted and recreated, the secret changed. - Check your clock skew if you enforce a timestamp tolerance.