Project Blue Dialer SDK

Place and receive calls on your Project Blue lines from your own product. Your workspace API key stays on your server; the browser only ever holds a 15‑minute token scoped to one line.

How it works

  1. Your server calls POST https://app.tryprojectblue.com/api/v1/dialer/token with your workspace API key (Settings → API Keys) and a lineId from GET https://api.tryprojectblue.com/get-lines.
  2. It returns a short‑lived token scoped to that line, plus today's minute allowance.
  3. Your browser code hands the SDK a tokenProvider that fetches that token from your server. The SDK refreshes it before it expires and before a token's per‑token call limit is reached.
  4. Every call is logged and metered against your dialer plan exactly like a call placed in the Project Blue app.
Never put the API key in a browser bundle or mobile app. It is a long‑lived workspace credential. Under a metered plan a leaked key is a direct cost to you. Tokens exist so that nothing long‑lived ever reaches a client.

1. Server: mint a token

POST https://app.tryprojectblue.com/api/v1/dialer/token

HeaderAuthorization: Bearer proj_… — the workspace API key.
Body{ "lineId": "…" } — a line id from GET https://api.tryprojectblue.com/get-lines (same API key). Two hosts: lines come from the messaging API, tokens from the app.
// Node / any server runtime
app.post('/dialer-token', requireLogin, async (req, res) => {
  const upstream = await fetch('https://app.tryprojectblue.com/api/v1/dialer/token', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PROJECT_BLUE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ lineId: req.user.projectBlueLineId }),
  });
  // Forward status, body and the rate-limit hint as-is; the SDK understands them.
  // Never let a non-JSON 502/503 from a proxy throw.
  const retryAfter = upstream.headers.get('retry-after');
  if (retryAfter) res.set('Retry-After', retryAfter);
  const body = await upstream
    .json()
    .catch(() => ({ error: 'Token service unavailable', code: 'dialer_unavailable' }));
  res.status(upstream.status).json(body);
});

Success response

{
  "token": "eyJ…",
  "expiresAt": "2026-09-01T12:15:00.000Z",
  "ttlSeconds": 900,
  "lineId": "0b3e0c59-ff0b-4110-faf6-6f976339a8c8",
  "inboundEnabled": true,
  "maxCallsPerToken": 10,
  "allowance": {
    "state": "ok",            // ok | soft | hard
    "used": 30,               // minutes used today, whole workspace
    "included": 180,          // minutes included today
    "cap": 540,               // workspace ceiling, null = no cap
    "pct": 16,
    "periodEnd": "2026-09-02T00:00:00.000Z",
    "line": { "used": 30, "cap": 540, "capped": false }
  }
}

Error responses

Non‑2xx responses are { "error", "code", … }:

StatuscodeMeaning
401invalid_api_keyMissing, malformed, unknown or revoked key.
429rate_limitedToo many mints for this key this minute. retryAfterSeconds and a Retry-After header are included.
400invalid_line_idlineId missing or not a line id.
404line_not_foundNo such line in your workspace.
403line_inbound_onlyThe line cannot place outbound calls.
403line_not_eligibleShared / trial lines are not available through the SDK.
403dialer_subscription_requiredThe workspace has no dialer subscription.
402account_paywalledThe account's payment is past due.
402dialer_cap_reachedThe line (or the whole workspace) has used its daily minute cap. Body carries used, included, cap, periodEnd. No tokens until periodEnd (midnight UTC); calls already in progress are never cut off.
404dialer_not_provisionedBusiness registration for calling is not complete.

2. Browser: place calls

npm install @tryprojectblue/dialer
import { ProjectBlueDialer, mintErrorFromResponse } from '@tryprojectblue/dialer';

const dialer = new ProjectBlueDialer({
  async tokenProvider() {
    const res = await fetch('/dialer-token', { method: 'POST' });
    const body = await res.json();
    const error = mintErrorFromResponse(res.status, body);
    if (error) throw error;          // typed: cap_reached, rate_limited, …
    return body;                     // the mint response, as-is
  },
});

dialer.on('allowance', (a) => {
  if (a.state === 'soft') showBanner(`${a.pct}% of today's minutes used`);
});
dialer.on('capReached', (error) => showBanner(error.message));

await dialer.start();

const call = await dialer.call('+15551234567');
call.on('ringing', () => setStatus('Ringing…'));
call.on('accepted', () => setStatus('Connected'));
call.on('disconnected', () => setStatus('Ended'));
muteButton.onclick = () => call.mute(!call.isMuted());
keypad.onkey = (digit) => call.sendDigits(digit);
hangupButton.onclick = () => call.hangup();
The SDK asks for microphone permission when the first call starts. Ask for it earlier in your own UI if you want to control the moment.

Inbound calls

When the line has an inbound number and someone in your workspace is in its ring set, tokens are minted with inboundEnabled: true and the SDK registers to receive calls for that line. Incoming calls ring your SDK session alongside the Project Blue app.

dialer.on('incoming', (call) => {
  showIncomingUi(call.remoteNumber, call.callerName, call.lineName, {
    answer: () => call.accept(),
    decline: () => call.reject(),
  });
  call.on('cancelled', hideIncomingUi);   // caller hung up before you answered
});

Plans, caps and limits

Reference

new ProjectBlueDialer(options)

option
tokenProviderrequired. () => Promise<mint response | token string>
refreshLeadSecondsRefresh this long before expiry. Default 60.
loggerconsole‑like object, or null to silence.
edgeMedia region hint. Leave unset unless measured.
playIncomingRingtoneDefault true.

Methods start(), call(to), refreshToken(), destroy(), isReady(). Getters allowance, inboundEnabled, activeCall. Events ready, allowance, capReached, incoming, tokenRefreshFailed, error, destroyed.

DialerCall

Fields direction, remoteNumber, callerName, lineName. Methods accept(), reject(), hangup(), mute(bool), isMuted(), sendDigits(digits), status(), isActive(). Events ringing, accepted, disconnected, cancelled, rejected, error, reconnecting, reconnected, mute, warning, warningCleared.

Errors

Every rejection is a DialerError with a code: cap_reached (DialerCapReachedErrorused, cap, periodEnd), subscription_required, account_paywalled, rate_limited (DialerRateLimitedErrorretryAfterSeconds), line_unavailable, not_provisioned, token_unavailable, invalid_state, microphone_unavailable, connection_failed, call_failed.