Support API

Gauge Support quick start

Wire your SaaS to Gauge Support in about 15 minutes: provision your users, embed the chat widget (or sign users into the portal), and receive verified webhooks.

The same flow is in the console at Developers → Support (/developers/support). There, every snippet is already filled with your base URL and project id, and each step has a Check that verifies it against your workspace. This page mirrors it.

You needWhere
A support projectSupport → Settings → Projects
Your API base URLDevelopers → Support (production: https://app.gauge.chat/backend)
Node ≥18 or Python ≥3.9 or curl—

SDKs: npm install @gauge/support (zero dependencies, ESM + CJS, Node ≥18 and edge runtimes) and pip install gauge-support (GaugeSupport / AsyncGaugeSupport over httpx, plus the identity hash and webhook helpers; Python ≥3.9). Both expose the same resources (projects, customers, tickets, sessions) with the same retry and Idempotency-Key rules.

Try it free first: the sandbox

Every workspace has one sandbox project, created the first time you open Developers → Support or make a request with a test key. Pick Sandbox · test keys only in the project picker and click Create test key: you get a pulse_k_test_ key that reaches the sandbox and nothing else. Run every step below against it.

  • It's free: it never counts toward your plan's projects, conversations, usage or analytics.
  • Nothing leaves the building: emails are recorded but never sent, there is no SMS, voice or AI spend, and webhooks arrive with livemode: false.
  • A test key on a live project, or a live key on the sandbox, is 403 with reason test_key_live_project / live_key_sandbox_project. Sandbox customers are invisible to live keys, and live customers to test keys, so the two never mix.
  • Reset sandbox (on the sandbox panel, typed confirmation) deletes its customers, tickets, chats and articles. The project id, your test keys and your widget embed keep working.

Find the sandbox's id with gauge.projects.list() (the row with isSandbox: true; a test key sees only that one). When you are ready, switch the picker to your real project and create a live key there.


Step 1 · Create a project-pinned key (2 min)

In Developers → Support, choose the project and click Create support key. You get a live key that can reach only that project. Every other project, and any customer reachable only through one, answers 404. Choose:

  • Sign customers in (support-sessions:rw): needed for portal sign-in links (step 3b).
  • Erase customers (GDPR) (support-erase:rw): leave it off unless your backend runs erasure.

support:rw is always included. *:rw never implies the two capability scopes. Store the key as GAUGE_API_KEY in your server's secret manager. It is shown once.

Test keys (pulse_k_test_) are for the sandbox: on the sandbox project the button reads Create test key, and that key works there and nowhere else. Live keys never reach the sandbox.

curl -s $BASE/v1/support/projects/$PROJECT_ID -H "Authorization: Bearer $GAUGE_API_KEY"
import { GaugeSupport } from '@gauge/support';
export const gauge = new GaugeSupport({ apiKey: process.env.GAUGE_API_KEY!, baseUrl: BASE });
await gauge.projects.get(PROJECT_ID);
import os
from gauge_support import GaugeSupport
gauge = GaugeSupport(os.environ["GAUGE_API_KEY"], base_url=BASE)   # or AsyncGaugeSupport
gauge.projects.get(PROJECT_ID)

Python mirrors every call below in snake_case with the API's camelCase bodies, e.g. gauge.customers.upsert_by_ref(str(user.id), {"email": ..., "projectIds": [PROJECT_ID]}), gauge.customers.sync(batch, idempotency_key=derive_idempotency_key("nightly", day, i)), and for c in gauge.customers.list_updated_since(since): ... then pager.high_watermark.

Check passes when a live key pinned to this project has made a request (on the sandbox: when a test key has). A workspace-wide key works but shows a warning, because a leak would expose every project.

Step 2 · Provision and sync your users (4 min)

Address every user by your own stable user id (externalRef), never by email. The full rules are in customer-sync.md.

On sign-up or change: upsert one user.

await gauge.customers.upsertByRef(user.id, {
  email: user.email, name: user.name, locale: 'ar',
  projectIds: [PROJECT_ID],          // authoritative within your key's projects
  metadata: { plan: user.plan },     // shown to agents; no secrets or regulated data
});
// → { outcome: 'created' | 'updated' | 'unchanged', customer }

Batch: up to 500 per call. Each item stands alone; check every error.

import { deriveIdempotencyKey } from '@gauge/support';
const run = await gauge.customers.sync(batch, { idempotencyKey: await deriveIdempotencyKey('nightly', day, i) });

The same Idempotency-Key with the same body replays the first answer, including the same runId. The same key with a different body is a 409.

Nightly reconcile.

const pager = gauge.customers.listUpdatedSince(new Date(Date.parse(W) - 5 * 60_000));
for await (const c of pager) await repair(c);
await saveWatermark(pager.highWatermark);   // exactly as returned; subtract the 5 min at READ time

Deactivate or block many users with gauge.customers.setStatus('deactivated', { externalRefs }). deactivated makes the user read-only; blocked locks them out and drops live sessions.

Check passes when at least one customer holds active access to the project.

Step 3a · Embed the widget (4 min)

Once per project, provision a chat line, turn chat on, require your server's hash and allow your origin. Then reveal the chat secret once and store it as GAUGE_CHAT_SECRET:

const p = await gauge.projects.get(PROJECT_ID);
await gauge.projects.update(PROJECT_ID, {
  version: p.version, identityMode: 'hmac_verified', chatEnabled: true,
  chatConnection: { mode: 'provision' }, chatAllowedOrigins: ['https://app.example.com'],
});
const { secret } = await gauge.projects.revealChatSecret(PROJECT_ID);   // a second reveal is 409

Per page view, on your server: sign the identity (v2: the user id and the time, so a leaked hash expires after 10 minutes).

import { signIdentity } from '@gauge/support';
const identity = await signIdentity(process.env.GAUGE_CHAT_SECRET!, user.id);
// { externalId, identityHash, identityTs }  — identityTs is epoch SECONDS
from gauge_support import sign_identity
identity = sign_identity(os.environ["GAUGE_CHAT_SECRET"], str(user.id))
TS=$(date +%s)
printf '%s:%s' "$USER_ID" "$TS" | openssl dgst -sha256 -hmac "$GAUGE_CHAT_SECRET" | sed 's/^.* //'

On the page: pin the live release with its integrity hash. The console fills in version and integrity from https://widget.gauge.chat/v1/manifest.json (files['widget.js']).

<script src="https://widget.gauge.chat/v1/<version>/widget.js"
  integrity="sha384-<integrity>" crossorigin="anonymous" async></script>
<script>
  window.addEventListener('load', function () {
    window.PulseWebchat.init({
      apiUrl: 'https://app.gauge.chat/backend',
      connectionId: '<project chatConnectionId>',
      visitorId: '{{ user.id }}',
      identityHash: '{{ identity.identityHash }}',
      identityTs: {{ identity.identityTs }},
      supportMode: true,
    });
  });
</script>

Or from a bundler: import { loadWidget } from '@gauge/support/browser' and call loadWidget({ version, integrity, apiUrl, connectionId, visitorId, identityHash, identityTs, supportMode: true }). It refuses to load a pinned version without a valid sha384- integrity, and an identityTs that is not whole epoch seconds.

Your page's CSP needs script-src https://widget.gauge.chat and connect-src https://app.gauge.chat wss://app.gauge.chat (details in widget-hosting.md §4).

identityHash v2 is the default. The widget sends the hash in the x-pulse-identity header and externalId + identityTs in the POST /support/chat/session body; the server accepts the timestamp within ±10 minutes. Because the hash is signed at page render, a page left open longer than that should re-render (or call PulseWebchat.init again) before the visitor opens chat. Once every page sends identityTs, turn on the project's chatIdentityTsRequired (gauge.projects.update(id, { version, chatIdentityTsRequired: true })) so a v1 hash, which never expires, is refused. v1 (identityHash(secret, userId), no identityTs) keeps working until then. Signing by hand? v2 is hex(HMAC-SHA256(secret, "v2\n" + userId + "\n" + identityTs)), deliberately not userId:identityTs, so no v1 hash can ever pass as v2.

Check passes when the manifest is reachable and the project has chat on, a chat line, and hmac_verified identity.

Step 3b · Or sign users into the portal or a WebView

Mint a single-use link per open (≤15 minutes; needs support-sessions:rw) and redirect to it. Never cache or share it.

const { url } = await gauge.sessions.mint({ projectId: PROJECT_ID, externalRef: user.id, email: user.email, ttlSeconds: 300 });
return Response.redirect(url, 302);

The project needs a usable portal address. A new project is given a platform address derived from the deployment's portal template (<key>.support.gauge.chat on Gauge), and a custom domain counts once verified under Support → Settings → Domains. With no usable address the mint answers 404 "No session link is available", the same answer as an unknown user, by design. If that happens, check the project's Domains page first.

Step 4 · Receive and verify webhooks (4 min)

Add an endpoint in Developers → Webhooks subscribed to support.customer.* (and support.ticket.* if you want tickets) and store its signing secret as GAUGE_WEBHOOK_SECRET. Your receiver must:

  1. Verify the signature on the raw body. x-pulse-signature is sha256=<hex HMAC-SHA256(secret, ${x-pulse-timestamp}.${rawBody})>. During a rotation on our side it carries two comma-separated values, and any match passes.
  2. Reject anything more than 5 minutes from your clock. x-pulse-timestamp is epoch milliseconds.
  3. Dedupe on the body's id. It is stable across retries and manual resends. Drop livemode: false in production: it marks events about the sandbox project.
  4. Answer 2xx within 10 s and do the work asynchronously. Failed deliveries retry at 30 s · 5 m · 1 h · 6 h.
// Next.js route handler (Node or Edge)
import { verifyWebhook } from '@gauge/support';
export async function POST(req: Request) {
  const r = await verifyWebhook([process.env.GAUGE_WEBHOOK_SECRET!], await req.text(), req.headers);
  if (!r.valid) return new Response(r.reason, { status: 400 });
  if (await seen(r.event.id)) return new Response(null, { status: 200 });
  await queue.add(r.event);
  return new Response(null, { status: 200 });
}
# FastAPI (Django and Flask recipes: packages/sdk-python/README.md)
from gauge_support import verify_webhook
@app.post("/gauge/webhooks")
async def gauge_webhook(request: Request):
    r = verify_webhook([os.environ["GAUGE_WEBHOOK_SECRET"]], await request.body(), request.headers)
    if not r.valid:
        raise HTTPException(400, r.reason)
    if not seen(r.event["id"]):
        enqueue(r.event)
    return {"ok": True}

Hold both secrets in the list while you rotate. Comparison is constant-time, and every secret × signature pair is checked.

Check passes when an enabled endpoint hears support customer events and its last delivery succeeded. Use Send test event on the endpoint page to trigger one.


Errors you will meet

HTTPcode / reasonMeaning
401unauthorizedkey missing, unknown, revoked or expired
403forbidden / test_key_live_projecta pulse_k_test_ key naming a live project (test keys reach only the sandbox)
403forbidden / live_key_sandbox_projecta live key naming the sandbox project
403forbiddenthe key lacks the scope or capability (support-sessions:rw, support-erase:rw)
403customer_inactiveticket for a blocked or deactivated customer
404not_foundabsent, another tenant's, or outside your key's projects (indistinguishable on purpose)
409conflictstale version, or an Idempotency-Key reused with a different body
422email_required · email_in_use · email_unavailablesee customer-sync.md §2
429rate_limitedhonour Retry-After (the SDK does)

Both SDKs raise a typed class per status (Python: GaugeValidationError … GaugeConnectionError with .status, .code, .reason). The Node SDK raises (GaugeValidationError, GaugeAuthenticationError, GaugePermissionError, GaugeNotFoundError, GaugeConflictError, GaugeRateLimitError, GaugeServerError, GaugeConnectionError) carrying status, code and reason. It retries 429, 5xx and network errors only where a repeat cannot double a write.

Verified end to end

On 2026-10-14 this flow ran against a local stack twice. First with a live pinned key (30 assertions, dx-e2e/transcript.txt). Then with a test key against the sandbox (41 assertions, dx-e2e/transcript-sandbox.txt), using both SDKs: the sandbox is created on first visit; steps 1–4 pass with the test key in Node and in the Python client; identity v2 opens a widget session while v1 and an 11-minute-old v2 hash are refused; every webhook arrives with livemode: false; the ticket emails are recorded sent / sandbox and never handed to the transport; a test key on a live project and a live key on the sandbox are both 403; and Reset sandbox deletes the test data while the project, its id and its chat line survive. The transcripts are kept with the support-embed programme notes.

On this page