Skip to content

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:

AllowlistWhat it containsWhat it gatesWho manages it
Embed allowlist (widget_origins, per tenant)Your CRM origins, e.g. https://crm.example.comWho may frame the widget (CSP frame-ancestors) and who the frame will talk to over postMessageYou, via /v1/widget_origins with your tenant API key
Widget-host origins (deploy constant)The Dialer Digital host that serves the frameAuthentication: agent login and every ddw_ API request must originate from the widget host pageThe 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

#RequirementHow
1Widget lane enabled in your environmentOperator go/no-go — ask your Dialer Digital contact.
2Your CRM origin on the tenant's embed allowlistPOST /v1/widget_origins with your tenant (dd_) API key — see below.
3Agent login credentials enabledPOST /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.
4Your 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_...):

MethodPathNotes
GET/v1/widget_originsList all origins (enabled + disabled).
POST/v1/widget_originsBody {"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}/enableRe-enable.

Origins are normalized to a strict canonical form and matched exactly at runtime:

  • https:// only — plain http origins are rejected.
  • Lowercase host; a default :443 port 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.com is invalid — add each host), no null, ASCII hostnames only (internationalized hostnames are rejected).

A malformed value is a 400; a duplicate is a 409.

Embedding the frame

html
<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 sandbox attribute if you can. If your platform forces one, the frame needs at least allow-scripts allow-same-origin allow-forms to 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 request Origin against the widget-host allowlist. A token exfiltrated out of the frame is useless from anywhere else (fail-closed 403).
  • 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 a dd_ key). Any 401 inside 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:

MethodPathPurpose
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 · hangupCall control — ownership-gated to the agent's own calls.
POST/v1/call_attempts/{id}/dispositionLog a typed disposition.
POST/v1/campaigns/{id}/dialPreview dial.
POST/v1/callbacksSchedule a callback.
POST/v1/widget_tokens/renewSlide the session expiry (rate-limited per token).
POST/v1/widget_tokens/introspectThe 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.

DirectionMessageMeaning
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.origin is 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:hello sent too early is lost — retry the hello until you receive dd:ready.
html
<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-tenant Content-Security-Policy whose frame-ancestors is 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-Options is dropped for this path only (it cannot express an allowlist and would override the CSP). connect-src is 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 answers origins: [] for unknown ids — it is not a tenant-existence oracle.

Common errors

SymptomCause
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 subdomainOrigins 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-configThe widget lane is disabled in this environment (operator switch).
401 unauthorized on loginWrong 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 loginCredentials were correct but the workspace/account is suspended.
429 rate_limited on loginPer-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-shiftThe 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 arriveYour 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 originNot 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.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.