@dialpad/dialapp-external-sdk
v0.4.2
Published
Headless SDK (no UI components) for partner apps in Dialpad's right sidebar. It talks to the Dialpad web and desktop apps over postMessage, keeps the current user, contact, call and theme in one immutable state snapshot, reports the panel's height and con
Maintainers
Keywords
Readme
@dialpad/dialapp-external-sdk
The SDK for partner apps in Dialpad's right sidebar. It's headless: it ships no UI components, so you build the panel with your own (the reference app uses Vue 3 and Dialtone 10). The SDK owns everything that talks to Dialpad:
- The host protocol over
postMessage, which you never call directly. - One immutable state snapshot: the Dialpad user, the contact being viewed, the active call, the theme, and the connection to your system.
- Connect: linking the Dialpad user to your system (for example, your CRM) through an OAuth popup, completed by polling your backend.
- The panel's height, and the menu you give the panel's header in Dialpad.
- Mock Dialpad hosts for unit tests and browser tests.
Your backend renders the panel page and puts the page data this SDK reads into
it. The reference app,
sample-dialapp-external,
shows how in its server/, and its guide/ walks through building an app.
Install
npm install @dialpad/dialapp-external-sdkThe /vue and /vue/testing entry points need vue 3.3 or later, and
/vue/testing needs @vue/test-utils 2.4 or later; the other entry points need
neither. While the SDK is 0.x, a minor version can change what your app sees,
and a patch version doesn't.
Supported browsers: Chrome 109 and later, and the Dialpad desktop app. The package is ES modules only; use a bundler.
The panel is 318 pixels wide in the right sidebar by default (266 to 414 as the user resizes it) and a fixed 230 pixels in the mini call bar, so lay it out for 230.
How it fits together
- Dialpad frames your panel page with
?idtoken=…. Your backend decodes it, renders the page, and puts the page data in it: an access token you issue and the user's external connection to your system. The reference app'sdialpad/idtoken.pyanddialpad/panel_page.pydo both. - In the page,
createRightbar()reads that page data, completes the handshake with Dialpad, and loads the active call, theme, Dialpad user and contact. - Your UI reads
rightbar.stateand calls the SDK's actions. Requests to your own API carryrightbar.authHeaders(), so your backend knows the user. - For Connect, your backend serves two endpoints under
/dialpad, which the SDK calls with that header:GET /dialpad/ext-connection, answering with anExternalConnection({ connected, authorization_urls, name, scopes }), andPOST /dialpad/ext-disconnect, answering204with no body. The contract is the package'sopenapi.json(@dialpad/dialapp-external-sdk/openapi.json).
Start
import { createApp } from 'vue';
import { createRightbar } from '@dialpad/dialapp-external-sdk';
import { rightbarPlugin } from '@dialpad/dialapp-external-sdk/vue';
import App from './App.vue';
import { api } from './api'; // your own HTTP module, with the fetch signature
const root = document.getElementById('app')!;
const rightbar = await createRightbar({ heightRoot: root, fetch: api });
createApp(App).use(rightbarPlugin, rightbar).mount(root);createRightbar() rejects with a RightbarError whose code is bad_options,
not_framed (the page isn't open inside Dialpad), bad_page_data or
init_timeout. Show a short "Open this panel from Dialpad" message in that
case.
In a component:
const { state, authorize, disconnect } = useRightbar();
const contact = computed(() => state.value.contact);Without Vue, read the state and follow its changes yourself:
const rightbar = await createRightbar({
heightRoot: document.getElementById('app')!,
});
rightbar.watch(
(state) => state.call,
(call) => renderCall(call),
);
rightbar.watch(
(state) => state.contact,
(contact) => renderContact(contact),
);State
rightbar.state;
// { ready, user, contact, call, theme: { mode, brand },
// connection: { connected, pending, authorizationUrls, name, scopes, error } }Every change produces a new snapshot that keeps the references of unchanged
parts. rightbar.subscribe((next, prev) => …) and
rightbar.watch((s) => s.contact, (contact) => …) follow it. connection.error
is null, { code: 'request_failed', status }, { code: 'network' } or
{ code: 'timeout' }.
user is what Dialpad's get/user gives at start (IDs, names, emails, phone
numbers, licenses, admin flags and availability), or null. Dialpad sends no
event when it changes, so its status fields are as of the panel's start.
connection.scopes lists the OAuth scopes your client has, so you can hide
actions the user can't take.
When the contact changes
contact changes while the panel runs. When the user opens another contact,
Dialpad keeps your panel loaded and sends the new contact (or none, null): the
panel stays connected, and its menu, refresh button and height stay as they
were. Watch state.contact and, on each change:
- Unlink at once with
linkRecord(null). Dialpad clears the link itself, but a link you sent for the previous contact just before the change can land on the new one. - Show a loading state, never the previous contact's record, until your lookup of the new contact answers. Ignore a lookup that answers for a contact no longer shown.
- Link the new contact's record once you find it.
refreshContact doesn't fire for a new contact: it means the refresh button, or
a change to the open contact's record in Dialpad.
Actions and events
| Call | Does |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| authorize(client?) | Asks Dialpad to open an authorize URL; polls until connected or 2 minutes |
| refresh() / disconnect() | Calls your backend's connection and disconnect endpoints |
| authHeaders() | { Authorization: 'Bearer <access token>' } for your own API calls |
| setMenu([{ id, label }, …]) / setMenu([]) | Sets the panel's menu in Dialpad to exactly these items, in order (see The panel menu) |
| on('menuSelect', (id) => …) | The user picked the menu item with this id; returns an unsubscribe function |
| linkRecord({ type, url }) / linkRecord(null) | Links a record in your system: Dialpad shows View <type> in the panel's header |
| showRefreshButton(show), showToast({ text, type }), screenPop() | Dialpad's refresh button, toasts and screen pops |
| on('refreshContact', callback) | Dialpad's header refresh button or a change to the contact's record; returns an unsubscribe function |
| createRightbar({ onDisconnect }) | Called after disconnect() succeeds |
The panel menu
Dialpad draws a menu in your panel's header, and you decide what's in it: each
item is your own { id, label }. When the user picks one, your menuSelect
listener gets its id. Dialpad gives no id a meaning of its own, so a
Disconnect item does whatever your listener does, usually disconnect():
rightbar.on('menuSelect', (id) => {
if (id === 'disconnect') void rightbar.disconnect();
if (id === 'rematch') rematch(); // your own: forget the matched record, look it up again
});
rightbar.watch(
(state) => state.connection.connected,
(connected) => {
const items = connected
? [
{ id: 'rematch', label: t('Rematch') },
{ id: 'disconnect', label: t('Disconnect') },
]
: [];
rightbar.setMenu(items).catch(console.error);
},
);- The menu is exactly what you set, in that order, whether or not the user
is connected;
[]means no menu. Each call replaces the last. A reloaded panel has no menu until it callssetMenu(), so set it whenever the panel starts, as thewatchabove does. The SDK never sets it for you. - The labels are yours to translate. Dialpad shows them as plain text, never
HTML, and cuts off one too wide for the menu with an ellipsis. The user's
language is
state.user.language(a Dialpad language code such asen,deorja), read when the panel starts; fall back to your default language when you have no translation. - Dialpad's limits: at most 5 items; each
id1 to 64 characters and unique in the menu; each label 1 to 40 characters, not only whitespace. More than 5 items rejects the whole call, and the menu stays as it was. An item that breaks the other rules, or repeats an earlierid, is left out, and the SDK logs one warning naming theids left out. - View <type> in the header follows
linkRecord()alone, whatever the menu holds. - The SDK doesn't tell Dialpad whether the user is connected: your menu says what the user can do.
Connect
Connect links the Dialpad user to their account in your system (for example, your CRM) through your own OAuth flow.
- Your Connect button calls
authorize(). Dialpad opens the URL inconnection.authorizationUrls(a browser tab on desktop, a popup on the web), andconnection.pendingturnstrue. When a company has several OAuth clients, show their names and pass the chosen one:authorize('sandbox'). - The user signs in. Your OAuth callback stores their tokens and returns the
close-popup page (the reference app's
connect/close_popup.py). - Meanwhile the SDK checks
GET /dialpad/ext-connectionafter 7 seconds, then every 5 seconds. A failed check doesn't end Connect; the next one decides. - When
/dialpad/ext-connectionanswersconnected: true, the state turnsconnected: true, pending: false. After 2 minutes without that, Connect stops withpending: falseanderror: { code: 'timeout' }; offer to try again.
Disconnect works the same way from your button and from a Disconnect item in
your menu, as both call disconnect(): the SDK calls
POST /dialpad/ext-disconnect, shows the panel as not connected (keeping the
authorization URLs, so Connect shows right away, and checking
/dialpad/ext-connection for current ones when the answer has no body), and
calls onDisconnect. If the call fails, the state keeps connected and sets
connection.error.
Your backend's side
Your backend implements its side of the contract in any stack; the reference
app's server/ is one implementation to copy.
| Endpoint | Authenticated by | Answers |
| -------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| GET <conversation sidebar URI> | ?idtoken=<JWE> | 200 the page, with the headers below; 401 a minimal error page when the token doesn't decode; 403 when you refuse the user |
| GET /dialpad/ext-connection | Authorization: Bearer <token> | 200 an ExternalConnection |
| POST /dialpad/ext-disconnect | Authorization: Bearer <token> | 204 with no body (a 200 ExternalConnection also works) |
| Your OAuth callback | Your own OAuth state | 200 the close-popup page, after storing the tokens; the path is yours |
The idtoken. A JWE (dir, A192CBC-HS384, keyed with the first 48
characters of your client secret) wrapping an HS256 JWT signed with the whole
secret. Allow only those algorithms, and answer 401 on any failure. It carries
the Dialpad user (user_id, company_id, office_id, and profile fields) and
an api_key for Dialpad's public API. It never expires and neither does the
key, so keep the key on your server and never log the page's query string. Drop
the csr claim and ignore claims you don't know.
Your access token. The idtoken authenticates only the page request. While
rendering the page, issue or look up your own access token for that Dialpad user
and put it in the page data; the SDK sends it as a bearer token, and
authHeaders() gives the same header to your own calls. A 401 from your
backend means your token was rejected, and what the panel does then is up to
you; a 401 from Dialpad's API means the api_key no longer exists, so drop
it.
The page data, JSON-encoded with <, >, & and ' escaped, in the page
you render:
<script type="application/json" id="dialpad-page-data" nonce="…">
{"version": 1, "dialpad_origin": "https://dialpad.com",
"access_token": "…", "external_connection": {…}, "app": null}
</script>app is passed through to rightbar.app for your own bootstrap data.
Headers on the page: Content-Security-Policy with
frame-ancestors <dialpad origin>, script-src 'self' 'nonce-<nonce>' (plus
your asset origin), object-src 'none' and base-uri 'none'; a fresh nonce per
request on every inline script; Referrer-Policy: no-referrer, so the idtoken
in the URL doesn't leak to your asset hosts; Cache-Control: no-store; and
X-Content-Type-Options: nosniff. The Dialpad origins are https://dialpad.com
and, for beta, https://dialpadbeta.com.
The close-popup page is required. It posts close_popup to its opener, to
the Dialpad origin only, when it has one, then closes itself, with a short "you
can close this window" fallback. On the web that resolves Dialpad's pending
Connect request; the desktop app's login window has no opener, so there the page
only closes it and the SDK's polling completes Connect.
The package's openapi.json (@dialpad/dialapp-external-sdk/openapi.json) has
the full schemas.
Testing
| Helper | Entry point |
| -------------------------------------------------------------------------------------- | ---------------- |
| createMockHost(), with host.createRightbar(), advance(), settle(), received | /testing |
| mountMockHostPage({ src }) for real browsers (Playwright, a dev page) | /testing |
| makePageData(), makeUser(), makeContact(), makeCall(), disposeAllMockHosts() | /testing |
| Setup file for Vitest or Jest | /testing/setup |
| mountWithRightbar(component, options) | /vue/testing |
// vitest.config.ts
test: { environment: 'jsdom', globals: true, setupFiles: ['@dialpad/dialapp-external-sdk/testing/setup'] }createMockHost() runs in memory with its own clock, so a Connect test calls
await host.advance(7_000) instead of using fake timers. mountMockHostPage()
frames your real page, whose backend renders the page data from a mock idtoken
(the reference app's local mode serves such a URL at /dev/frame-url), so it
takes no page data itself.
Both mock hosts answer as Dialpad does, with its limits, and record what the
panel asked for in host.received: linkedRecord, refreshButtonShown,
toasts, screenPops, authorizeRequests, every message, and menu: the
items Dialpad kept from the panel's latest setMenu(), as { id, label }, or
null until the panel sets one after its latest init. To play the user, call
host.selectMenuItem(id), which picks an item of that menu (it returns false,
sending nothing, for an id not in it), and host.trigger('refresh_contact')
for the header's refresh button.
host.sendContact(contact) opens another contact, as Dialpad does: it clears
received.linkedRecord, and the panel gets the new contact in state.contact
without a reload or a refreshContact. host.updateContact(contact) changes
the open contact's record instead: the host sends its silent refresh, so the
contact is reloaded and refreshContact fires.
A first test, with Vitest and the setup file above:
import { expect, test } from 'vitest';
import {
createMockHost,
makeCall,
makeContact,
} from '@dialpad/dialapp-external-sdk/testing';
test('shows the call as it connects', async () => {
const host = createMockHost({ contact: makeContact({ displayName: 'Ada' }) });
const rightbar = await host.createRightbar({
heightRoot: document.createElement('div'),
});
expect(rightbar.state.contact?.displayName).toBe('Ada');
host.sendCall(makeCall({ state: 'connected' }));
await host.settle();
expect(rightbar.state.call?.state).toBe('connected');
await rightbar.linkRecord({ type: 'Contact', url: 'https://crm.example/1' });
expect(host.received.linkedRecord?.url).toBe('https://crm.example/1');
});Pass fetch to host.createRightbar() to answer the Connect endpoints. With
globals: true (or in Jest) the setup file disposes every mock host after each
test; otherwise call disposeAllMockHosts() in your own afterEach.
License
MIT, copyright 2026 Dialpad, Inc. The full text is in the LICENSE file in the
installed package (node_modules/@dialpad/dialapp-external-sdk/LICENSE).
