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
- Your server calls
POST https://app.tryprojectblue.com/api/v1/dialer/tokenwith your workspace API key (Settings → API Keys) and alineIdfromGET https://api.tryprojectblue.com/get-lines. - It returns a short‑lived token scoped to that line, plus today's minute allowance.
- Your browser code hands the SDK a
tokenProviderthat fetches that token from your server. The SDK refreshes it before it expires and before a token's per‑token call limit is reached. - Every call is logged and metered against your dialer plan exactly like a call placed in the Project Blue app.
1. Server: mint a token
POST https://app.tryprojectblue.com/api/v1/dialer/token
| Header | Authorization: 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", … }:
| Status | code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Missing, malformed, unknown or revoked key. |
| 429 | rate_limited | Too many mints for this key this minute. retryAfterSeconds and a Retry-After header are included. |
| 400 | invalid_line_id | lineId missing or not a line id. |
| 404 | line_not_found | No such line in your workspace. |
| 403 | line_inbound_only | The line cannot place outbound calls. |
| 403 | line_not_eligible | Shared / trial lines are not available through the SDK. |
| 403 | dialer_subscription_required | The workspace has no dialer subscription. |
| 402 | account_paywalled | The account's payment is past due. |
| 402 | dialer_cap_reached | The 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. |
| 404 | dialer_not_provisioned | Business 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();
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
- Metering. Every completed call is recorded in seconds against the line and attributed to the API key that minted the token.
- Daily allowance. Each subscribed line gets its plan's included minutes per UTC day.
allowance.stateturnssoftat the workspace's warning threshold and the mint refuses withdialer_cap_reachedonce a line (or every line) hits its cap. - Tokens. 15‑minute lifetime, one line each, and at most
maxCallsPerTokenoutbound calls. The SDK refreshes ahead of both limits. - Mint rate limit. Per API key, per minute. One token per active user session is plenty; do not mint per call.
Reference
new ProjectBlueDialer(options)
| option | |
|---|---|
tokenProvider | required. () => Promise<mint response | token string> |
refreshLeadSeconds | Refresh this long before expiry. Default 60. |
logger | console‑like object, or null to silence. |
edge | Media region hint. Leave unset unless measured. |
playIncomingRingtone | Default 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 (DialerCapReachedError — used, cap, periodEnd), subscription_required, account_paywalled, rate_limited (DialerRateLimitedError — retryAfterSeconds), line_unavailable, not_provisioned, token_unavailable, invalid_state, microphone_unavailable, connection_failed, call_failed.