Appearance
Agent widget (embed)
Español: Widget del agente.
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, dial a typed number and schedule callbacks (see What the session token can call); the reference frame UI exposes call control today and, once released, a dialpad for typing a number; the other surfaces are 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.
Audio in the frame: possible, and OFF unless your operator armed it
The widget was a control surface only, and can now also carry the voice: when the platform operator has armed the in-frame voice lane for your environment, the frame registers a softphone of its own using a SIP credential minted for that one agent session and the call rings inside the frame.
Two things to know before you plan around it:
- It is per environment, and off by default. Where the lane is not armed, nothing breaks and nothing changes: the call keeps ringing on the agent's seat device (the dashboard's browser softphone or an external SIP device) and the frame says so. Confirm with your operator.
- Your
<iframe>must grant the microphone, and should grant autoplay (allow="microphone; autoplay", see Embedding the frame). The two attributes cover the two directions and they fail differently: without the microphone the agent hears the call and is not heard; without autoplay a Chromium-based browser can refuse to play the incoming audio, and then the agent does not hear — which nobody on the other end can tell you, because from there everything sounds normal.
The frame also has a dialpad for typing a number — shipped in core v0.86.0 (published as v0.86.0-prod-0.1): see Dialing a typed number (dialpad) for what it does and the Roadmap for its status.
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: the supported integration surface on your side is the iframe plus the postMessage bridge. A browser page whose Origin is not the widget host fails closed for login and ddw_ requests. Origin is a browser-origin gate, not proof against an arbitrary HTTP client that can construct headers, so do not build a backend token relay around it.
Click-to-call for your website (ddc_)
The click-to-call lane lets a visitor ask your team to call a number from your website. It uses a separate public site key with the ddc_ prefix. The key is publishable: it identifies the configured click-to-call site, but it is not a password and does not authenticate the visitor.
Setup and request flow
Your tenant administrator creates a click-to-call site, chooses its destination campaign and limits, and receives the ddc_ key once. Do not treat it as a tenant API key. Revoking the site invalidates the key. The platform stores only a SHA-256 hash of the complete key, including its prefix.
The key is used by the click-to-call frame, which, like the agent widget, is served from the Dialer Digital widget host. A ddc_ request is accepted only when its Origin is a widget-host origin; one sent from your own page gets 403. Your website embeds the frame, and your widget_origins decide where it may be embedded (frame-ancestors). The frame and its loading snippet are not released yet: until they are, there is no supported way to submit a request from your website.
The public lane exposes exactly two routes:
| Method | Path | Purpose |
|---|---|---|
GET | /v1/public/call-request-config | Read the public configuration for the site |
POST | /v1/public/call-requests | Submit a request for an immediate or scheduled call |
The request body accepts only the normalized E.164 phone number, mode (immediate or scheduled), the scheduled time when the mode is scheduled, the version, SHA-256 hash, and checked state of the consent text shown by your site, and an optional captcha_token. The server supplies the site, tenant, and receipt time; never accept those values from browser input. The server also records the request's Origin and User-Agent headers. Any other body field is a 400 bad_request.
The submission response is deliberately uniform and asynchronous: 202 means only that the request was accepted for processing. It does not mean that a call will be placed. The platform resolves the number to an account later, applies the configured compliance gates and calling window, and may record a refusal, block, expiry, or dispatch instead.
The lane is off unless both the platform ceiling and your tenant's click_to_call_enabled setting are on. While it is off, both public routes return the ordinary 404 response and do not write a request. Ask your platform operator before publishing the snippet.
Frame language: lang=
The click-to-call frame accepts an optional lang query parameter in the <iframe> URL: lang=es or lang=en. Any other value is ignored. Without it, the frame uses the default language of your cell.
The snippet's frame URL already carries a query string (?tenant=…), so append the parameter with &, right after tenant=…, and before any # fragment. A second ? would corrupt the tenant value.
html
<iframe src="https://app.usa.dialerdigital.com/…?tenant=YOUR-WORKSPACE-ID&lang=es"></iframe>lang only selects the language of the frame's text. It does not authenticate the visitor, change which site or tenant the request is for, or alter the consent text your own page shows and reports.
Threat model for the public key and Origin
| Boundary | What it does | What it does not do |
|---|---|---|
ddc_ site key | Selects a non-revoked click-to-call site and its tenant configuration | Does not identify the visitor, prove control of the phone, or act as a tenant API key |
Origin | Must be the Dialer Digital widget-host origin of the frame; another browser origin, including your page, gets 403 | Is not a secret, identity proof, consent, or a control against a non-browser client that forges headers |
| Public route list | Limits the key to configuration and call-request submission | Does not grant access to tenant, agent, reporting, or widget routes |
202 response | Confirms receipt for asynchronous processing | Does not confirm account matching, compliance, or a placed call |
Assume that anyone who can load the click-to-call frame can copy the ddc_ key and replay requests. Do not put trust decisions, visitor identity, or consent proof in the key or in Origin. Protect the page that embeds the frame against XSS, do not log the phone number or key unnecessarily, and keep the key out of tenant-admin credentials and server secrets. v1 has no captcha: the request path has a seam for one, but no provider ships, so the durable tenant, number, and site caps are what bound abuse.
The durable caps are independent of the in-memory rate limiter: tenant and number windows cover the preceding 24 hours, and the site window covers the preceding hour. If the cap data cannot be read, the request is rejected rather than admitted. A cap rejection is not evidence that the number matched an account.
What this product does not claim
The click-to-call lane does not claim that any of these events is consent from the person whose number was entered:
- publishing or possessing a
ddc_key; - sending a request with an allowed-looking
Origin; - checking a consent box or sending a consent-text version and hash;
- receiving
202, or a later callback, dispatch, or successful call.
The request records what the visitor declared and what text version your site reported. It does not establish who controls the phone, does not create a consents record, and does not replace tenant configuration or the platform's compliance gates. Configure and assess any required consent or other calling basis in your normal compliance workflow.
Security guide and threat model
Treat the widget as a browser client, not as a private service embedded in your application. The ddw_ value is a public browser credential: it must be usable by the frame, so an attacker who can inspect the frame's runtime or browser traffic may obtain it. The design limits what that credential can do and how long it remains useful; it does not make the token secret.
What each boundary means
| Boundary | What it proves or limits | What it does not prove |
|---|---|---|
?tenant= workspace id | Selects the workspace whose widget configuration is requested | It does not authenticate a person or grant access by itself |
Embed allowlist (widget_origins) | Which exact CRM origins may frame the widget and receive its postMessage metadata | It does not authorize agent login or make the CRM a trusted identity provider |
Widget-host allowlist (widget_host_origins) | Which deployed widget document origin may mint or present a ddw_ token | It does not identify the tenant, the agent, or the person who initiated an action |
Origin on a request | A browser-origin gate for the widget lane; a request from another origin fails closed | It is not a secret, a user identity, or proof of intent. Do not use it as consent |
ddw_ token | A short-lived, agent-scoped credential for the closed route list | It is not a tenant API key, an admin credential, or consent from the called person |
The two origin lists are intentionally different. Your CRM origin is used for embedding and the message peer; the Dialer Digital widget host is used for authentication. The public workspace id selects the tenant, while the credential and server-side tenant checks authorize the agent session. Never replace this separation with a same-origin proxy or by copying the widget host into every tenant's embed list.
Threats the widget is designed to contain
| Threat | Containment | Integrator responsibility |
|---|---|---|
| A page from an unlisted origin tries to frame the widget | The response uses a fail-closed frame-ancestors policy | Register every real https origin, including each subdomain and non-default port |
| A stolen token is replayed by a browser from a CRM backend or another page | A browser request with the wrong widget-host Origin fails; the token is also short-lived, revocable, and route-limited | Do not log, copy, proxy, or put the token in URLs, HTML, localStorage, or sessionStorage. Origin is not a control against a non-browser client that can forge headers |
| The embedding page tries to read the frame's DOM or token | The iframe's cross-origin boundary keeps the frame's DOM and JavaScript memory separate | Keep the integration on the iframe plus the documented postMessage messages |
An unexpected page sends postMessage data | The frame accepts messages only from an enabled embed origin; outbound messages use the pinned exact origin | Validate event.origin before handling messages and never use * as targetOrigin |
| A compromised allowlisted CRM page observes widget events | The bridge exposes only presence and call-state metadata, never credentials or phone numbers | Treat CRM XSS as a real security incident; protect the allowlisted page and its dependencies |
| An attacker probes tenant existence through public widget configuration | Unknown workspace ids return an empty origin list rather than tenant details | Treat the workspace id as publishable configuration, not as a secret |
These controls are defense in depth. They do not protect a compromised widget-host deployment, a compromised browser, malicious browser extensions, or an XSS vulnerability in the allowlisted CRM. They also do not turn a browser header into an identity assertion: non-browser clients can construct headers, and a legitimate origin can still be controlled by hostile code.
What the product does not claim
The widget does not claim that any of the following is consent from the person being called:
- embedding the frame or placing a CRM origin on the allowlist;
- possessing the public workspace id or presenting a valid
Originheader; - logging an agent into the frame or presenting a valid
ddw_token; - clicking a widget control, typing a number, or sending
postMessagedata.
The widget request remains subject to the platform's call and compliance gates. A successful authentication, an allowlisted origin, and a permitted route establish only the technical conditions for the signed-in agent's request; they do not establish legal consent, replace tenant configuration, or make a legal determination. Configure and assess any required consent or other calling basis in the tenant's normal compliance workflow.
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. Setting or clearing it takes a tenant (dd_) key or an account_admin login; a ddu_ login only on its own seat. A member login on another agent's seat gets 403 role_forbidden. |
| 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. |
Adding, disabling and re-enabling an origin take a dd_ key or an account_admin dashboard login; a member login gets 403 role_forbidden and nothing changes. Listing is not affected. The rule sits behind the per-account setting telephony_config_admin_only (on by default, set by the platform); see Who can write telephony settings.
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
html
<iframe
id="dd-agent-widget"
src="https://app.usa.dialerdigital.com/widget?tenant=0d4f4f9e-1f2a-4b53-9d3c-8a5e2f7b1c10"
title="Dialer Digital agent widget"
allow="microphone; autoplay"
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. allow="autoplay"is what lets the agent HEAR. A browser only plays audio on its own initiative in a document that the user has interacted with, and a cross-origin frame does not inherit your page's interaction unless you delegate autoplay to it. Measured on Chrome 153 and Chromium 151: with the agent's click landing on YOUR page and never inside the frame, the call audio is silent without this attribute and audible with it; Firefox 153 does not apply that restriction. It is not a substitute for a click — no browser plays audio in a frame nobody has touched at all, with or without the attribute — and today the agent's sign-in inside the frame is that first interaction. Grant it anyway: it costs nothing, and it is what keeps the frame audible the day the audio starts without a click of its own.allow="microphone"is what lets the agent be HEARD. A cross-origin frame gets no microphone unless the embedding page delegates it (Permissions Policy), and the denial is silent from your side: the agent hears the call and the other party hears nothing. Grant it even if voice is not armed in your environment yet — it costs nothing while the frame does not ask for the microphone, and it is the one line that is easy to forget on the day the lane is turned on.- No camera grant is needed, and none is asked for: the frame is voice only.
- If your platform forces a
sandboxattribute, note thatsandboxandalloware independent — granting the microphone still requiresallow="microphone", and the same holds forautoplay.
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 browser replay from another origin fails closed with403; this browser-origin gate is not a substitute for treating the token as public or for defending against arbitrary clients that can construct headers. - 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). Each call carries debtor_name, the name of its account (null when there is none or the call has no debt); it is added for the agent's own calls only and a dd_ key does not receive it. |
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/dial/manual | Dial a typed number, resolved to one of the tenant's accounts — the agent can only name themself. Shipped in core v0.86.0 (published as v0.86.0-prod-0.1); see Dialing a typed number (dialpad). |
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). |
Dialing a typed number (dialpad)
Shipped in core v0.86.0 (published as v0.86.0-prod-0.1)
POST /v1/dial/manual and the dialpad in the reference frame are in the released contract, so the verb is in the API Reference. Confirm with your operator that your environment runs a build that includes it.
The dialpad is an idle-agent tool: the frame offers it once it has read the agent's call list and only while the agent has no live call. The agent types an E.164 number (+ and 7 to 15 digits; no country code is ever guessed). The frame strips spaces, dashes, dots and parentheses and checks the format before anything leaves the browser; the server checks it again and strips nothing, so an API caller must send the bare E.164 form.
A typed number is not a free-form dial. Core resolves it against the phones of your tenant's own accounts:
- exactly one account holds the number → the call goes through the same pipeline as a preview dial (the account's own campaign, the agent's seat, and the compliance gates — consent, DNC, the destination-local calling window, Reg F frequency, state rules), and is attributed to that account;
- no account, several accounts, an account with no campaign to dial through, a quarantined number, or a malformed number →
422with a machine-readableerror.codeand no call is placed. A number that belongs to no account is not dialable from the widget.
json
POST /v1/dial/manual
{ "number": "+13125550100", "agent_id": "<the signed-in agent's id>" }| Outcome | Meaning |
|---|---|
200 { "status": "dialing", "call_id": "…" } | The call was placed. |
200 { "status": "blocked", "reason": "…" } | A compliance gate refused the call — not a placed call, same as a blocked preview dial. |
422 invalid_number · no_account · ambiguous_account · no_campaign · number_quarantined | The number does not resolve to exactly one dialable account; nothing was dialed. |
503 audit_unavailable · resolution_unavailable | Not placed and retryable: the request could not be recorded, or the account lookup failed. |
403 | The token names another agent, or none (a ddw_ may only name its own agent_id). |
409 · 429 · 404 · other 503 codes | The outcomes of the preview-dial verb, unchanged (for example, the campaign is not running, or registration_unverifiable). |
Every request is written to an append-only audit trail — who typed which number, and each block reason — before anything is dialed; if that record cannot be written, the call is not placed. Two limits to plan around: an account that can only be reached through a debt list (no campaign of its own) answers 422 no_campaign, and the campaign must be running with a human-initiated mode, as for a preview dial.
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.
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-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.