Skip to main content
Version: v0.23.5

Tour the dashboard

The dashboard is a web UI for everything you would otherwise drive through the API: create and connect sessions, scan QR codes inline, register webhooks with a visual filter builder, and — as an admin — mint and revoke API keys. This guide walks you through each area and what it does.

Prerequisites
  • A running OpenWA instance. See Quick start if you don't have one yet.
  • An OpenWA API key to sign in with. Generating one is covered in Authentication.

Sign in and what you'll see​

The dashboard ships inside the OpenWA process. With a default install it's served from the same origin and port as the API — open http://localhost:2785/ in a browser and sign in with your API key.

What you can see is role-gated. The key's role (admin, operator, or viewer) decides which pages mount in the sidebar. Admin-only pages are not just hidden — a non-admin who types the URL directly is redirected away.

PageWhat it's forRole
DashboardOverview: session counts, message volume, recent activityall
SessionsCreate, start, stop, show the QR, and delete sessionsall
ChatsBrowse chat threads, updated live over WebSocketall
WebhooksRegister and manage per-session webhook endpointsall
TemplatesManage reusable message templatesall
Message TesterSend any message type (text, media, location, contact card, sticker, poll), forward a message, run a bulk text batch, or check a numberall
LogsActivity and audit trailadmin
API KeysCreate, list, and revoke API keysadmin
InfrastructureRuntime status and configurationadmin
PluginsInstall, enable, and configure plugins. An ingress-capable plugin's config modal carries an Instances tab for provisioning Integration Fabric instances, one per external account, each with its own inbound webhook and secretadmin

The sidebar footer holds a theme toggle that switches between light and dark; the choice is remembered in your browser and applied before first paint, so there is no flash of the wrong theme. The accent-palette picker was removed in v0.10.0. There is no separate Settings page.

The dashboard is a thin front end

Every action below maps to a documented API route. The dashboard calls the same http://localhost:2785/api endpoints you'd call with curl. Where a page maps to an endpoint, this guide names it so you can script the same workflow.

Sessions: connect a number with an inline QR​

The Sessions page is where a WhatsApp number goes from "created" to "connected." Each row shows the session's live status, so you can see at a glance which numbers are online.

From a row you can:

  • Create a session and name it — POST /api/sessions.
  • Start or stop it — POST /api/sessions/{sessionId}/start, POST /api/sessions/{sessionId}/stop.
  • Show the QR to link a phone — GET /api/sessions/{sessionId}/qr.
  • Set an egress proxy, at create time or afterwards from the row's Proxy action: GET / PATCH /api/sessions/{sessionId}/proxy (since v0.23.4). The modal shows the host and whether credentials are stored, never the URL itself, and a read that fails is shown as a failure rather than as "no proxy configured", so saving from that state cannot silently clear a proxy nobody got to see. The change applies on the session's next start.
  • Delete it when you're done — DELETE /api/sessions/{sessionId}.

Opening a row's detail panel shows the session's name, status, id, phone, and timestamps, plus an Auto-reject calls toggle (since v0.14.5). Flipping it writes autoRejectCalls through PATCH /api/sessions/{sessionId}/config, so the setting no longer needs an API call to reach. It applies from the next incoming call — no restart — and call.received still fires first, so a webhook consumer sees the call either way. The toggle is read-only for a viewer key, and it is omitted entirely when the config could not be read, rather than rendering as off and asserting something the dashboard does not know. See Sessions & multi-session — Read and change a session's config for the other two settings the same route carries.

Each row also surfaces a restriction badge when WhatsApp itself has placed a limit on the account (a reachout timelock, a TOS block, or a proxy block). The badge arrives live over the same WebSocket that drives the row status, so it appears and clears the moment the underlying session.restriction event fires — no page reload needed (since v0.14.0). See Sessions & multi-session — Account restrictions for what each kind means.

The inline QR view​

When you start a session that needs linking, a QR Code view opens right on the Sessions page — you don't fetch and render the QR yourself. The view refreshes the code by itself: while it is open the dashboard re-reads the QR every 5 seconds and also applies codes pushed over the WebSocket, so an expired code is replaced without any action from you. There is no countdown and no refresh button.

Scan it from your phone under WhatsApp → Linked Devices → Link a Device. Once the link succeeds the session flips to ready and the QR view closes on its own.

The connect modal also offers a Link with Phone Number tab next to the QR code — a QR-free way to link a device. Enter a phone number in international format (digits only, no +), and the dashboard requests an 8-character pairing code via POST /api/sessions/{sessionId}/pairing-code. Type that code on the phone under Linked Devices → Link with phone number. The phone field uses a numeric keypad, and the code display is isolated to left-to-right so it reads correctly even in right-to-left locales.

Under the hood the page re-reads the QR from the API every 5 seconds while the view is open, and applies refreshed codes pushed live over a WebSocket as well, which is why the image updates without a page reload. The QR endpoint returns a ready-to-render data URL:

curl http://localhost:2785/api/sessions/8f3c2b1a-9d4e-4c7a-8b2f-1e6d5a4c3b2a/qr \
-H "X-API-Key: YOUR_API_KEY"
{
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANS...",
"status": "qr_ready"
}

If the session is already linked, the same call returns 400 (QR code not ready or session already authenticated) — the dashboard shows the session as connected instead of opening the QR view.

For the full session lifecycle and the same flow in curl and the SDK, see Sessions & multi-session.

Webhooks: build event filters without writing JSON​

The Webhooks page lists every webhook for a session with its URL, the events it subscribes to, and whether it's active. From here you can:

  • Add a webhook — URL, the events it fires on, an optional signing secret, custom headers, and filters (POST /api/sessions/{sessionId}/webhooks).
  • Edit a webhook, or enable/disable it without deleting (PUT /api/sessions/{sessionId}/webhooks/{id}).
  • Test it — send a synthetic delivery and see whether your endpoint accepts it (POST /api/sessions/{sessionId}/webhooks/{id}/test).
  • View logs of past deliveries.

The visual filter builder​

The event picker offers every event OpenWA emits, plus * to subscribe to all of them. It is kept in step with the backend list, so anything named in the event catalog can be selected here, including session.restriction, presence.update, group.join_request, and the three call outcomes.

Beyond the event list, the page includes a filter builder for narrowing which matching events actually get delivered. You compose conditions as field / operator / value rows in the UI; the dashboard serializes them into the webhook's filters object, so you never hand-write that JSON. For example, "only deliver message.received from one chat" becomes a single condition row instead of a JSON blob.

Use Test before relying on a webhook in production — it sends a sample payload to your URL so you can confirm the endpoint is reachable and returns a 2xx, without waiting for a real WhatsApp event.

For the payload shape of each event and how to verify the HMAC signature on every delivery, see Webhooks.

API keys (admin): create, copy once, revoke​

The API Keys page is admin-only. It's where you provision the keys that every other client — including the dashboard itself — signs requests with.

From the page you can:

  • Create a key with a name and a role (viewer, operator, or admin; default operator), and, for an operator or viewer key, restrict it to chosen sessions (POST /api/auth/api-keys, since v0.23.4). Leaving the picker empty keeps access to every session, including ones created later. Admin keys stay unscoped in the dashboard, and there is no IP-allowlist field here: allowedIps is REST-only.
  • Re-scope an existing operator or viewer key from the same page (PUT /api/auth/api-keys/{id}, since v0.23.4). Saving a scope change is an authorization change, so every live /events socket holding that key is disconnected; an unchanged Save is not sent at all.
  • List existing keys by prefix, role, and usage (GET /api/auth/api-keys).
  • Revoke a key to disable it (POST /api/auth/api-keys/{id}/revoke), or delete it (DELETE /api/auth/api-keys/{id}).
The full key is shown only once

When you create a key, the response includes the complete secret in the apiKey field — and that is the only time it's ever returned. Copy it immediately. Afterward, every list view shows only keyPrefix (the first 12 characters — the owa_k1_ prefix plus 5 hex chars), never the full value. If you lose it, revoke the key and create a new one.

Creating a key over the API returns the copy-once secret alongside its metadata:

curl -X POST http://localhost:2785/api/auth/api-keys \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "n8n integration", "role": "operator"}'
{
"id": "a1b2c3d4-...",
"name": "n8n integration",
"keyPrefix": "owa_k1_3f9a2",
"role": "operator",
"isActive": true,
"usageCount": 0,
"createdAt": "2026-06-26T10:00:00.000Z",
"apiKey": "owa_k1_3f9a...full-secret-here..."
}

The dashboard surfaces that apiKey value in a copy-to-clipboard panel at creation, then drops it. Every subsequent list call returns the same record without the apiKey field — only keyPrefix for identification.

For roles, what each can do, and IP/session scoping, see Authentication.

Infrastructure (admin)​

The Infrastructure page reflects runtime status from GET /api/infra/status and lets an admin reconfigure the stack in place via PUT /api/infra/config.

  • Database health is probed with a live SELECT 1 on each connection (the same probe /health/ready uses), not a cached isInitialized flag — so a PostgreSQL backend that dies after boot shows as down instead of staying green.
  • Webhook queue depth reports real BullMQ job counts (waiting + active + delayed, plus completed/failed). The phantom "Message Queue" card (no such queue exists) and the dead "Clear Failed Jobs" button (it had no handler) have been removed. View Bull MQ Dashboard copies the URL with a hint — a plain browser tab can't send the required ADMIN X-API-Key header, so opening one only 401'd.
  • Postgres schema (POSTGRES_SCHEMA) is exposed as a field, placing OpenWA's tables and migration ledger in a dedicated schema (default public; SQLite ignores it). The schema must already exist.
  • If /infra/status fails to load, the page shows an error and a Retry instead of an editable form seeded from defaults — so a save can no longer flip a running built-in database/Redis/storage to external + empty.
  • A rejected PUT /api/infra/config (an unknown engine type, or a value carrying a newline that would inject an extra env var) now returns its real 4xx status, not 200 { saved: false }. A genuine persistence fault still returns 200 with { saved: false }.
  • GET /api/settings requires an admin key, and now reports the real enableDocs (from ENABLE_SWAGGER), apiBaseUrl (from BASE_URL), and autoReconnect (the engine's actual default, on) — instead of hardcoded values.
  • When a setting on any infra card is being supplied by a higher-precedence layer, the card shows a "Pinned by an environment variable" notice that names the variable doing the pinning (since v0.14.2). A saved-but-not-restarted change is labelled separately so you can tell the two apart. The Engine card gained this notice for the first time in the same release — it had no pin indicator before.
  • GET /api/infra/config reports each field's effective value with boot precedence: the host environment first, then the project .env, then data/.env.generated (since v0.20.0). Before that, a value pinned outside the file stack (a Compose-set ENGINE_TYPE, DATABASE_TYPE, or REDIS_ENABLED) read back as the first-run default, so the form showed a pinned stack as unconfigured.

Other tools​

  • Message Tester sends any message type ad-hoc and runs a number check (GET /api/sessions/{sessionId}/contacts/check/{number}) — useful for validating a session before wiring it into an integration. You can also forward an existing message to another chat, and a bulk text batch shows live progress in the response panel and can be cancelled mid-run. See Sending messages for the underlying send endpoints.
  • Chats shows live chat threads. New messages arrive over a WebSocket, so the thread updates without a refresh. The chat view renders call messages (voice/video, with "missed" for an unanswered call), shared locations (a 📍 Location preview instead of a raw base64 thumbnail), native polls, and masked business messages (a short notice explaining the text is only available on the primary phone). When inbound media is skipped (MEDIA_DOWNLOAD_ENABLED=false, or the item is over the byte cap), the bubble shows a 📎 Media placeholder. The same placeholder appears when the media was downloaded but did not fit the message list's per-response inline budget (MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES). The placeholder is a button, not a dead label: clicking it fetches the bytes through the per-message media route, and it reports Media unavailable when they genuinely are not there. Since v0.23.4 a download that failed outright keeps the placeholder too, instead of rendering as an empty bubble.
  • Status viewer plays a voice status with an inline <audio> player. Voice statuses are Ogg/Opus blobs, so earlier builds rendered them as a broken image; since v0.14.0 the viewer picks the right element for the media type (image, video, or audio) and exposes playback controls.
  • Language. The dashboard ships thirteen locales — Arabic, German, English, Spanish, French, Hebrew, Italian, Korean, Brazilian Portuguese, Telugu, Turkish, Simplified Chinese, and Traditional Chinese (Hong Kong). Turkish was added in v0.17.0. Each locale is fetched on demand rather than bundled: before v0.17.0 all thirteen shipped in one 476 KB chunk the page preloaded, so every visitor downloaded twelve languages to read one. If you cache or front the dashboard assets with a CDN, the locale catalogues are now separate chunks requested at runtime, not part of the initial bundle. Text direction follows the catalogue that actually rendered, so a catalogue that fails to load falls back to English left-to-right instead of leaving right-to-left layout around English copy (since v0.17.0).
  • Logs (admin) gives you an activity and audit trail across the instance (GET /api/audit). API-key lifecycle events are recorded here: api_key_created, api_key_deleted, api_key_revoked (each with the acting admin key, client IP, and target key), and api_key_auth_failed for rejected or denied keys.

Troubleshooting​

ProblemCauseFix
Sidebar is missing API Keys / Infrastructure / Plugins / LogsYour key isn't an admin keySign in with an admin key, or have an admin grant you one. Roles are described in Authentication.
A session awaiting its QR shows no Show QR buttonYour key is a viewer keyThe QR is operator-only over both REST and the socket, so the button renders only for a key with write access. Use an operator or admin key.
QR view never shows a codeThe session is already connected, or hasn't been startedStop and restart the session, or check the row status — a connected session has no QR.
QR scanned but row stays "awaiting QR"The code expired before the phone finished linkingA fresh code arrives on its own every few seconds; scan the next one promptly. If none arrives, stop and start the session again.
Webhook Test returns a failureYour endpoint is unreachable or didn't return 2xxConfirm the URL is publicly reachable from the instance and responds with a 2xx. Check View logs for the status it saw.
You can't find a key's full secretThe full value is only shown once, at creationRevoke the old key and create a new one; copy it immediately this time.

For wider issues, see Troubleshooting.

Next steps​