Agent widget (embed)
The agent widget is a drop-in <iframe> that puts a signed-in agent call surface inside your own CRM or web app. Each agent signs in with their own email and password inside the frame — your application never handles a Dialer Digital API key or any shared credential — and every call action they take is attributable to that person, in real time.
Inside the frame the agent sees their live calls and can act on them (hold / unhold, mute / unmute, hang up). The same ddw_ session that powers the frame is also authorized — at the API level — to log dispositions, place preview dials and schedule callbacks (see What the session token can call); the reference frame UI exposes call control today, with those surfaces tracked on the Roadmap. The frame keeps itself fresh over the platform's live event feed; your page can subscribe to a small postMessage event stream for presence and call-state.
Availability: the widget lane ships dark
The entire widget lane is behind a server-side switch (widget_auth_enabled) that is a go/no-go decision of the platform operator per environment. While the lane is off, POST /v1/auth/agent-login and GET /v1/auth/widget-config answer 404, and the widget page serves Content-Security-Policy: frame-ancestors 'none' — it cannot be embedded anywhere. Everything below documents the contract as built; confirm with your operator that the lane is enabled for your environment before integrating.
No audio inside the frame (yet)
The widget is a control surface, not a softphone: voice audio rides the agent's existing seat device (the dashboard's browser softphone or an external SIP device). The frame sees and controls those calls. An in-frame softphone is designed but not available — see the Roadmap.
How it fits together
your CRM page (https://crm.example.com)
└── <iframe src="https://app.usa.dialerdigital.com/widget?tenant=<workspace-id>">
│ the widget frame — served from the Dialer Digital dashboard host,
│ with a per-tenant CSP (frame-ancestors = YOUR allowlisted origins)
│
├── talks DIRECTLY to the core API, cross-origin (fetch + WebSocket)
└── postMessage bridge to your page (presence / call-state events only)Production serves the frame from https://app.usa.dialerdigital.com. Non-production tiers follow app.<tier>.usa.dialerdigital.dev (app.dev.usa.dialerdigital.dev, app.staging.usa.dialerdigital.dev); swap the host if you integrate against one of them.
The frame is served from the Dialer Digital dashboard host and calls the core API directly (cross-origin on purpose — the browser then attaches an Origin header on every request, which the widget token lane verifies). Your page and the frame are separated by the browser's iframe origin boundary: scripts on your page cannot reach the frame's DOM, JS heap, or the agent's session token.
Two allowlists — do not confuse them
Two different origin checks are involved, and they deliberately diverge:
| Allowlist | What it contains | What it gates | Who manages it |
|---|---|---|---|
Embed allowlist (widget_origins, per tenant) | Your CRM origins, e.g. https://crm.example.com | Who may frame the widget (CSP frame-ancestors) and who the frame will talk to over postMessage | You, via /v1/widget_origins with your tenant API key |
| Widget-host origins (deploy constant) | The Dialer Digital host that serves the frame | Authentication: agent login and every ddw_ API request must originate from the widget host page | The platform operator |
A practical consequence: you cannot call POST /v1/auth/agent-login or replay a ddw_ token from your own backend or your own page — requests whose Origin is not the widget host fail closed. The only supported integration surface on your side is the iframe plus the postMessage bridge.
Prerequisites
| # | Requirement | How |
|---|---|---|
| 1 | Widget lane enabled in your environment | Operator go/no-go — ask your Dialer Digital contact. |
| 2 | Your CRM origin on the tenant's embed allowlist | POST /v1/widget_origins with your tenant (dd_) API key — see below. |
| 3 | Agent login credentials enabled | POST /v1/agents/{id}/credential sets/rotates an agent's widget password; DELETE disables it (and revokes their live widget session). The agent must be active. |
| 4 | Your workspace id (public tenant UUID) | Provided at onboarding. It is a publishable identifier — it selects the workspace, it grants nothing by itself. |
Managing the embed allowlist: /v1/widget_origins
Authenticated with your tenant API key (Authorization: Bearer dd_...):
| Method | Path | Notes |
|---|---|---|
GET | /v1/widget_origins | List all origins (enabled + disabled). |
POST | /v1/widget_origins | Body {"origin": "https://crm.example.com", "label": "..."}. 201 returns the canonical form. |
DELETE | /v1/widget_origins/{id} | Soft-disable: the origin drops out of frame-ancestors/postMessage. Origins are never deleted. Does not block agent login (that gates on the widget host). |
POST | /v1/widget_origins/{id}/enable | Re-enable. |
Origins are normalized to a strict canonical form and matched exactly at runtime:
https://only — plainhttporigins are rejected.- Lowercase host; a default
:443port is stripped; non-default ports are kept and must match. - A web origin, nothing more: no path, query, fragment, userinfo, or trailing slash.
- No wildcards (
https://*.example.comis invalid — add each host), nonull, ASCII hostnames only (internationalized hostnames are rejected).
A malformed value is a 400; a duplicate is a 409.
Embedding the frame
<iframe
id="dd-agent-widget"
src="https://app.usa.dialerdigital.com/widget?tenant=0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10"
title="Dialer Digital agent widget"
style="width: 360px; height: 520px; border: 0;"
></iframe>?tenant=is your workspace id (?tenant_id=also works). A missing or malformed value renders an in-frame error and, because the embed CSP is computed from it, an unembeddable page.- Load the widget directly from the Dialer Digital host. Do not proxy or re-serve the page from your own domain: the per-tenant security headers are stamped per response (
Cache-Control: no-store), and the auth model depends on the frame living on the widget host origin. - Avoid a
sandboxattribute if you can. If your platform forces one, the frame needs at leastallow-scripts allow-same-origin allow-formsto function. - No permissions-policy grants (microphone, camera) are needed — there is no audio in the frame.
If the browser refuses to render the frame (“refused to connect”), see Common errors — it is almost always the fail-closed embed CSP.
The agent session (ddw_ token)
When an agent submits the in-frame login form, the frame calls POST /v1/auth/agent-login with {tenant_id, email, password} and receives a ddw_ browser token — a short-lived, agent-scoped credential distinct from tenant API keys (dd_) and dashboard sessions (ddu_).
Properties of the session, as enforced server-side:
- Memory-only. The token lives exclusively in the frame's JS memory — never in
localStorage,sessionStorage, cookies, or URLs. A page reload means the agent signs in again. This is deliberate: XSS on the embedding page can never read the credential. - Short-lived, sliding. The token has a sliding expiry (default 15 minutes) that the frame renews automatically (
POST /v1/widget_tokens/renew), up to an absolute session cap fixed at login (default 8 hours; the storage engine enforces a hard 12-hour ceiling). Renewal slides the expiry — it never moves the cap, and it never changes the token plaintext. Defaults are per-deployment configuration. - One live session per agent. Minting a new session for an agent supersedes any previous live one.
- Origin-checked on every request. Every
ddw_API call re-verifies the requestOriginagainst the widget-host allowlist. A token exfiltrated out of the frame is useless from anywhere else (fail-closed403). - Default-deny API surface. The token reaches only an enumerated set of routes (below) — it can never mint API keys, manage widget tokens or origins, or touch supervision/admin/reporting surfaces.
- Revocable. Sessions end when: the sliding expiry lapses or the cap is hit; the agent's credential is rotated or disabled (
/v1/agents/{id}/credential— the revoke is transactional with the credential change, so no session survives it); or a workspace admin revokes the token (DELETE /v1/widget_tokens/{id}with add_key). Any401inside the frame drops the session and returns the agent to the login form.
Login failures are deliberately uninformative: an unknown workspace, unknown email, wrong password, or login-disabled agent all return the same 401. A suspended workspace returns 403 only after the password verified. Login attempts are rate-limited per email (fixed window, 429 rate_limited, no Retry-After header).
What the session token can call
The complete ddw_ surface — everything else is 403:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/calls, /v1/calls/{id} | The agent's live/recent calls (server-scoped to the signed-in agent). |
POST | /v1/calls/{uuid}/hold · unhold · mute · unmute · hangup | Call control — ownership-gated to the agent's own calls. |
POST | /v1/call_attempts/{id}/disposition | Log a typed disposition. |
POST | /v1/campaigns/{id}/dial | Preview dial. |
POST | /v1/callbacks | Schedule a callback. |
POST | /v1/widget_tokens/renew | Slide the session expiry (rate-limited per token). |
POST | /v1/widget_tokens/introspect | The session's own context (agent, clocks, enabled origins). |
The frame also holds a WebSocket to the live event feed to refresh itself; that connection is internal to the frame — your page does not interact with it.
Talking to the frame: the postMessage bridge
The frame exposes a minimal, versioned event stream to the embedding page. It is presence/lifecycle metadata only — tokens, credentials, and phone numbers never cross the bridge.
| Direction | Message | Meaning |
|---|---|---|
| host → frame | { "type": "dd:hello" } | Handshake. The first dd:hello from an allowlisted origin pins that origin as the frame's only peer. |
| frame → host | { "type": "dd:ready", "v": 1 } | Handshake acknowledged; events will follow. |
| frame → host | { "type": "dd:session", "v": 1, "state": "signed_in" | "signed_out" } | The agent signed in / the session ended. |
| frame → host | { "type": "dd:call_state", "v": 1, "live": <number> } | The agent's live-call count changed. |
Security behavior you should design around:
- The frame processes a message only if
event.originis on the tenant's enabled embed allowlist. Anything else is silently dropped — never answered, so there is no probing oracle. If your origin is not allowlisted, the bridge is simply deaf. - Outbound events are sent with your exact pinned origin as
targetOrigin(never*); only one peer is ever pinned per frame load. - The frame fetches its allowlist before it starts listening. A
dd:hellosent too early is lost — retry the hello until you receivedd:ready.
<iframe id="dd-agent-widget" src="https://app.usa.dialerdigital.com/widget?tenant=YOUR-WORKSPACE-ID"></iframe>
<script>
const WIDGET_ORIGIN = 'https://app.usa.dialerdigital.com';
const frame = document.getElementById('dd-agent-widget');
window.addEventListener('message', (ev) => {
if (ev.origin !== WIDGET_ORIGIN) return; // trust nothing else
const msg = ev.data;
switch (msg?.type) {
case 'dd:ready': clearInterval(hello); break;
case 'dd:session': console.log('agent is', msg.state); break;
case 'dd:call_state': console.log('live calls:', msg.live); break;
}
});
// Handshake: retry until the frame answers dd:ready.
const hello = setInterval(() => {
frame.contentWindow?.postMessage({ type: 'dd:hello' }, WIDGET_ORIGIN);
}, 500);
</script>Security headers on the widget page
For the curious (you do not have to configure any of this):
GET /widget?tenant=<uuid>is served with a per-tenantContent-Security-Policywhoseframe-ancestorsis exactly your enabled embed origins, fetched from the same source agent-login uses (GET /v1/auth/widget-config, public, cached up to 60 s). The embed gate and the runtime gate can never diverge.- Fail-closed: no tenant parameter, a malformed one, an empty allowlist, the lane being dark, or the config source being unreachable all collapse to
frame-ancestors 'none'— an unembeddable page. X-Frame-Optionsis dropped for this path only (it cannot express an allowlist and would override the CSP).connect-srcis restricted to the frame itself plus the core API origin and its WebSocket twin.GET /v1/auth/widget-config?tenant_id=<uuid>returns{"origins": [...]}— your enabled embed origins. It is public by design (the same strings already ship inside response headers) and answersorigins: []for unknown ids — it is not a tenant-existence oracle.
Common errors
| Symptom | Cause |
|---|---|
| Browser refuses to render the iframe (“refused to connect” / blank) | The embed CSP is frame-ancestors 'none': the widget lane is off, the ?tenant= parameter is missing/malformed, your origin is not on the enabled allowlist, or the allowlist could not be fetched. After fixing the allowlist, allow up to 60 s for the header cache. |
Frame renders “Missing or malformed ?tenant=” | The tenant query parameter is absent or not a UUID. Check the embed snippet. |
Frame loads on https://crm.example.com but not on http://... or another subdomain | Origins match exactly (scheme + host + port). http origins can never be allowlisted; each subdomain needs its own entry. |
404 from /v1/auth/agent-login or /v1/auth/widget-config | The widget lane is disabled in this environment (operator switch). |
401 unauthorized on login | Wrong email/password, agent inactive, login not enabled for the agent, or unknown workspace id — indistinguishable by design. Also returned when login is attempted from anywhere other than the widget frame. |
403 forbidden (“tenant is suspended”) on login | Credentials were correct but the workspace/account is suspended. |
429 rate_limited on login | Per-email login brake (fixed window). Retry later; no Retry-After header is set. |
403 “origin not allowed for this token” | A ddw_ request whose Origin is not the widget host — typically a replayed token outside the frame. By design. |
403 “this token may not use that endpoint” | The route is outside the ddw_ default-deny allowlist. Use a tenant API key from your backend instead. |
| Agent bounced back to the login form mid-shift | The session hit its sliding expiry or absolute cap, the credential was rotated/disabled, or an admin revoked the token. Signing in again is the intended recovery. |
postMessage events never arrive | Your origin is not on the enabled allowlist; or you sent dd:hello before the frame was listening (retry until dd:ready); or another window already pinned itself as the peer for this frame load. |
400/422 adding an origin | Not a canonical https origin — wildcard, path, port :443 written explicitly is fine (it is stripped), http, or a non-ASCII hostname. |
See also
- Authentication — tenant API keys (
dd_), the credential your backend uses. - WebSocket events — the live floor the frame consumes internally.
- Roadmap — in-frame softphone audio and other designed-but-not-built surfaces.