## Mint a token `POST https://app.tryprojectblue.com/api/v1/dialer/token` Exchange your API key for a 15-minute voice token pinned to one line. Call this from your server whenever the SDK's `tokenProvider` asks, and return the body to the browser as-is. > **Server to server only** > > The request carries your workspace API key, so it must never be made from a browser or a mobile app. Put an authenticated route in front of it on your own backend — the snippets in the rail are complete examples — and have that route decide which `lineId` the signed-in user may call from. ### Headers `Authorization: Bearer YOUR_API_KEY` and `Content-Type: application/json`. The same key you use against `api.tryprojectblue.com`. ### Request body - `lineId` (string, required) — The line to call from, exactly as returned by GET https://api.tryprojectblue.com/get-lines with the same API key. Calls are placed from that line's number and count toward its dialer plan. Shared and trial lines are refused. ### Response - `token` (string, required) — Opaque, short-lived voice token scoped to one line. Hand the whole response body to the SDK's tokenProvider; never store the token or build on its contents. - `expiresAt` (string (ISO 8601), required) — When the token stops working. Always 15 minutes from mint. - `ttlSeconds` (number, required) — Token lifetime in seconds. Currently 900. - `lineId` (string, required) — The line this token is pinned to, echoed back. - `inboundEnabled` (boolean, required) — Whether this token can also receive calls. True only when the line has an inbound number and someone in your workspace is in its ring set. Can differ from one mint to the next. - `maxCallsPerToken` (number, required) — Outbound calls one token may place before it must be refreshed. Currently 10. The SDK refreshes ahead of this limit, so you never hit it in normal use. - `allowance` (object | null, required) — Today's minute allowance for the workspace and for this line. Null only when the plan could not be read at mint time; the token is still valid. - `state` ("ok" | "soft" | "hard", required) — ok: within plan. soft: near the included minutes — warn your users. hard is never returned on a 200; a hard stop is a 402 dialer_cap_reached refusal instead. - `used` (number, required) — Whole minutes used today across the workspace. - `included` (number, required) — Minutes included in today's plan across the workspace. - `cap` (number | null, required) — Workspace ceiling in minutes for today. Null when the account has no cap. - `pct` (number, required) — used ÷ included, as a whole percent. - `periodEnd` (string (ISO 8601), required) — When today's allowance resets — midnight UTC. A capped line resumes calling at this time. - `line` (object | null, required) — This line's own standing against its daily cap. - `used` (number, required) — Minutes this line has used today. - `cap` (number | null, required) — This line's daily cap in minutes, or null for none. - `capped` (boolean, required) — True when this line has hit its cap. The next mint for it returns 402 even if the workspace still has minutes. ### Forward it unchanged Return the upstream status, body, and `Retry-After` header to the browser exactly as received. The SDK reads `expiresAt`, `maxCallsPerToken`, `inboundEnabled`, and `allowance` to schedule its own refreshes and to raise typed errors. If you reshape the body, refresh timing and error handling degrade to defaults. Guard the `.json()` call. A proxy in front of the app can return a non-JSON `502` or `503`, and an unhandled throw in your route becomes a generic `500` in the browser instead of a retryable `token_unavailable`. ### Refusals Every non-2xx body is `{ error, code }`, plus extra fields on the cap and rate-limit cases. Unlike the messaging API, `code` is always present here, and the SDK maps it to a `DialerError` code for you. | Status | code | SDK code | Meaning | | --- | --- | --- | --- | | `401` | `invalid_api_key` | `token_unavailable` | Missing, revoked, or wrong API key. | | `400` | `invalid_line_id` | `line_unavailable` | lineId missing or not a value from /get-lines. Refused before the rate-limit slot is spent. | | `404` | `line_not_found` | `line_unavailable` | Not one of this workspace's lines. Returned for other workspaces' lines too — existence is never confirmed. | | `403` | `line_inbound_only` | `line_unavailable` | The line receives calls but cannot place them. | | `403` | `line_not_eligible` | `line_unavailable` | Shared and trial lines are not an SDK surface. | | `403` | `dialer_subscription_required` | `subscription_required` | No Dialer add-on on the workspace. | | `402` | `account_paywalled` | `account_paywalled` | The account is past due. | | `402` | `dialer_cap_reached` | `cap_reached` | The workspace or this line has used today's minutes. Body carries used, included, cap, periodEnd. | | `404` | `dialer_not_provisioned` | `not_provisioned` | Business registration (Phone Compliance) not yet approved, so calling is not provisioned. | | `403` | `api_key_unsupported` | `token_unavailable` | A legacy key that cannot mint. Create a new key in Settings → API Keys. | | `429` | `rate_limited` | `rate_limited` | Too many mints for this key. Body carries retryAfterSeconds; a Retry-After header is set. | | `500` | `dialer_misconfigured` | `token_unavailable` | Calling is not fully configured for this account. Contact support. | | `503` | `dialer_unavailable` | `token_unavailable` | Token service temporarily unavailable. Retry with backoff. | > **The mint has its own rate limit** > > This endpoint allows 30 mints per minute per API key, separate from the [60 requests per minute](https://api.tryprojectblue.com/#rate-limits) on the messaging API. A well-behaved SDK session mints once at start, then roughly once every 14 minutes and once per 10 calls, so the limit only matters if your server mints on every page load. Malformed requests are refused before they consume a slot. ### Status codes - **200** — Token minted - **400** — invalid_line_id - **401** — invalid_api_key - **402** — dialer_cap_reached or account_paywalled - **403** — dialer_subscription_required, line_inbound_only, line_not_eligible, api_key_unsupported - **404** — line_not_found or dialer_not_provisioned - **429** — rate_limited — read retryAfterSeconds - **500** — dialer_misconfigured or internal_error - **503** — dialer_unavailable — retry with backoff **Request — cURL** Server to server only. Note the app host. ```bash curl -X POST https://app.tryprojectblue.com/api/v1/dialer/token \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "lineId": "0b3e0c59-ff0b-4110-faf6-6f976339a8c8" }' ``` **Request — Express** ```javascript // Your server. The API key never leaves it. 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); }); ``` **Request — Next.js** ```javascript // app/api/dialer-token/route.ts import { NextResponse } from 'next/server'; import { getSession } from '@/lib/auth'; export async function POST() { const session = await getSession(); if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 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: session.projectBlueLineId }), }); const body = await upstream .json() .catch(() => ({ error: 'Token service unavailable', code: 'dialer_unavailable' })); const headers = new Headers(); const retryAfter = upstream.headers.get('retry-after'); if (retryAfter) headers.set('Retry-After', retryAfter); return NextResponse.json(body, { status: upstream.status, headers }); } ``` **Response — 200 OK** ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6InR3aWxpby1mcGE7dj0xIn0…", "expiresAt": "2026-09-04T12:15:00.000Z", "ttlSeconds": 900, "lineId": "0b3e0c59-ff0b-4110-faf6-6f976339a8c8", "inboundEnabled": true, "maxCallsPerToken": 10, "allowance": { "state": "ok", "used": 30, "included": 180, "cap": 540, "pct": 16, "periodEnd": "2026-09-05T00:00:00.000Z", "line": { "used": 30, "cap": 540, "capped": false } } } ``` **Response — 402 — cap** used/cap are the line's figures when the line is what is capped. ```json { "error": "DIALER_CAP_REACHED", "code": "dialer_cap_reached", "used": 540, "included": 180, "cap": 540, "periodEnd": "2026-09-05T00:00:00.000Z" } ``` **Response — 403 — plan** ```json { "error": "Dialer subscription required", "code": "dialer_subscription_required" } ``` **Response — 404 — line** ```json { "error": "Line not found", "code": "line_not_found" } ``` **Response — 429** A Retry-After header carries the same number. ```json { "error": "Rate limit exceeded", "code": "rate_limited", "retryAfterSeconds": 42 } ```