Click2Call lets the people who work in your CRM, helpdesk or back-office make and receive phone calls through Callston without leaving your pages. Two pieces work together:
Outgoing calls are requested server-to-server by your backend. The phone number goes from your database to Callston directly; when the integration policy hides numbers, the employee's browser only ever sees an opaque call reference and the display name you chose.
Base URL of this installation:https://callston.com· SDK:https://callston.com/assets/js/click2call-sdk.js· OpenAPI:/developers/click2call/openapi.json
| Term | Meaning |
|---|---|
| Integration | One connection between a Callston account (personal, or a business) and one of your platforms. Has a public integration id (c2c_…), a list of exact allowed origins, a server key and a policy. |
| Server key | c2ck_… — held by your backend only. Creates widget sessions and requests calls. Shown once when created or rotated; stored hashed. Never put it in a page or a snippet. |
| Widget token | c2cw_… — a short-lived (30 min, renewable) credential for one agent on one origin. Your backend obtains it for the logged-in employee and hands it to the page. It can only read that agent's state and send controls. |
| Launch ticket | single-use, 10-minute ticket the widget requests to open the call window. Carried in the URL fragment, consumed once by the window, then removed. |
| Engine | a paired call window. One live engine per agent per integration. It registers with the provider under an opaque identity (c2c_<id>) inside the billing owner's provider account. |
| Call intent | a server-side record of a requested outgoing call with the policy frozen in it (number visibility, cap, rate). Delivered only to the authorized engine; consumed once by the provider webhook. |
| Agent identifier | how your backend names an employee: their Callston email or a stable UUID (integration setting). Identifiers select an existing, enabled, assigned member — they never create anything. |
https://crm.example.com (scheme + host + port, no path). Requests from any other origin are refused. Lines that are not origins (a path, no scheme) are not saved — the page says which. While you build and test, you can tick Allow all origins (for testing): any site may then use the integration's widget (your backend still decides who gets a session, and each session works only on the origin it was made for); the integration shows a warning until you switch it off and list your exact origins. Frame-shell mode always needs the exact origin.429 concurrency_limit.Rotating a key invalidates the old one immediately. Revoking the key stops your backend; revoking the integration also ends every widget session and call window at once (calls in progress end through the provider).
Every page load re-authenticates: your page asks your backend, your backend checks who is logged in and asks Callston for a widget token for that employee.
POST https://callston.com/api/v1/click2call/sessions
Authorization: Bearer c2ck_…
Content-Type: application/json
{ "integration_id": "c2c_…", "agent": { "type": "email", "value": "[email protected]" }, "origin": "https://crm.example.com" }
Response 201:
{ "ok": true, "widget_token": "c2cw_…", "expires_at": 1727612345,
"agent": { "name": "Dana", "identifier": "[email protected]" },
"policy": { "outbound": true, "inbound": true, "number_visibility": "hide", "controls": ["mute","dtmf"], "widget": { … }, "max_call_seconds": 3600 },
"engine": null }
Return the token (and, if you like, policy/engine) to the page. Errors: origin_not_allowed, agent_unknown, agent_ambiguous, agent_not_assigned, agent_disabled, identifier_mode, integration_disabled, platform_disabled.
The widget refreshes its token itself (POST /widget/refresh) before expiry and re-asks your session endpoint once if a token is rejected. Revocation, disabling or un-assigning an agent apply on the next request.
<script src="https://callston.com/assets/js/click2call-sdk.js"></script>
<script>
var widget = CallstonClick2Call.init({
integrationId: 'c2c_…',
sessionEndpoint: '/callston/session', // your backend → returns {widget_token, expires_at, …}
requestCallEndpoint: '/callston/call', // your backend → forwards to Callston with the number
mode: 'persistent-window',
windowMode: 'tab', // optional: 'popup' (default), 'tab' or 'extension' — overrides the integration setting
position: 'bottom-right', // optional visual overrides
language: 'en'
});
document.getElementById('enable-calls').onclick = function () { widget.enableCalls(); }; // inside a click
widget.on('statechange', function (s) { console.log(s.state, s.call); });
</script>
The SDK posts to sessionEndpoint and requestCallEndpoint with credentials: 'same-origin' and a JSON body; add your CSRF header with headers: { 'X-CSRF-Token': … } if your backend needs one.
Return Callston's answer unchanged from both endpoints — its HTTP status and JSON. The widget reads them: a refusal ({"ok": false, "error": "agent_offline"} with 409, for example) is shown to the agent at once; an accepted call (202) shows Requesting call… until the call window picks it up. An endpoint that always answers 200 "OK" hides Callston's refusals: the widget then waits, and after 30 s says The call did not start.
| Method | What it does |
|---|---|
enableCalls() | Must run inside a user gesture (not needed in frame-shell mode). Opens the call window — as a small popup window, a normal browser tab, the embedded shell panel or the browser extension, per the integration's Call window opens as setting or the windowMode init option (see Call window modes below). If a live window already exists it is asked to come to the front — the SDK never opens a replacement. If a popup is blocked the widget shows an "Open in new tab" link. Returns true when a window was opened or found. |
requestCall({ contact_reference, … }) | Posts to your requestCallEndpoint; your backend looks up the number and calls Callston. Resolves with your backend's response. |
answer(), decline() | Incoming call controls (delivered to the window). |
hangup() | Ends the current call: the server ends the provider legs and tells the window. Works from any page — and also when the call window lost the call or is gone: whatever the server still holds for the agent is ended at the provider. An unreachable provider is reported as hangup_uncertain, never as a confirmed hangup. |
setMuted(bool), sendDigits('123#') | When the policy enables the control. |
setAvailability(bool) | Whether incoming calls may ring this agent. |
getState() | { state, engine, call, policy, agent, session }. engine.client is window or extension. |
on(event, fn) / off | statechange, callrequested, detached, commandfailed ({ kind, error } — a control the server refused; the widget also shows the reason). |
detach() | Stops polling and removes the widget. Not a hangup: the call window keeps running. |
endSession() | Revokes the widget credential (use on logout). The window keeps its own session. |
States: init, connecting, offline (no call window), enabling, ready, unavailable, no_mic, call, reconnecting, unauthorized, error. call.status follows the intent lifecycle: queued, claimed, dialing, ringing, connected, completed, rejected, busy, no_answer, canceled, failed, expired; incoming calls report incoming then connected.
Multipage sites include the snippet on every page (the widget re-attaches to the existing window through the server). SPAs call init() once and endSession() on logout. The widget is rendered in a closed Shadow DOM, is keyboard operable, announces status changes to screen readers and switches to RTL for Hebrew and Arabic.
The call window owns the microphone and the audio, so it must live somewhere that survives your page loads. Call window opens as (per integration) picks where:
| Mode | Where the calls live | What you do | Best for |
|---|---|---|---|
| Popup window (default) | a small window on the Callston origin | nothing extra | most sites |
| New browser tab | a normal tab (agents can pin it) | nothing extra | agents who lose popups |
| Frame shell | an embedded panel inside a shell page on your site; your pages run in a full-screen frame | add one shell page (below) | classic multipage sites that should feel fully integrated, with no second window |
| Browser extension | the Callston extension for Chrome / Edge (a hidden background document) | nothing extra; agents install the extension once | teams that can install an extension; no window or panel at all |
All four use the same backend calls (sessions, calls, hangup) and the same widget. Only where the call engine runs changes.
In frame-shell mode, the agent's browser tab shows a shell page from your site. The shell shows your real pages in a full-screen, same-origin frame and keeps the Callston call window in a small embedded panel. Clicking links, submitting forms and reloading pages inside the frame never touches the call. No popup, no extra tab, and no click is needed to enable calls.
1. Add a shell page to your site, on the same origin as your app, for example https://crm.example.com/callston-shell.html:
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>CRM</title></head>
<body>
<script src="https://callston.com/assets/js/click2call-sdk.js"></script>
<script>
CallstonClick2Call.shell({
integrationId: 'c2c_…',
sessionEndpoint: '/callston/session', // the same endpoints as init()
requestCallEndpoint: '/callston/call',
start: '/crm/' // first page to show; ?p=/some/page overrides it
// headers: { 'X-CSRF-Token': … }, panelPosition: 'left' | 'right', autoEnable: true
});
</script>
</body>
</html>
2. Keep init() on your pages as it is. Inside the shell's frame, init() finds the shell and returns the shell's widget (widget.inShell === true): the same methods and events, one session, one widget UI (drawn by the shell, so it does not flicker on page loads). Listeners registered by a page are removed when that page unloads.
3. In Callston, set Call window opens as → Frame shell and enter the Shell page URL (/callston-shell.html or a full URL on the same origin). The shell page's origin must be in the integration's allowed origins; the Callston call window may then be framed by exactly those origins, and by nothing else.
4. Allow same-origin framing of your own pages. Your app pages are shown in a frame of your own shell, so they must not forbid that: use X-Frame-Options: SAMEORIGIN or Content-Security-Policy: frame-ancestors 'self' (not DENY / 'none'). If your pages have a CSP, it also needs frame-src https://callston.com on the shell page (for the call panel), plus the script-src / connect-src entries listed in Deployment notes.
What the shell does for you:
shell?p=/that/page. Opt out with init({ shellRedirect: false }), or call CallstonClick2Call.enterShell('/callston-shell.html') yourself early in the page to skip the extra round trip. Only same-origin paths are accepted for p.?p= is kept up to date), so reload, bookmarks and copy-link keep working. The browser's Back and Forward buttons move inside the frame.target="_top" stay in the frame. Links to other sites open in a new tab (most sites refuse to be framed).Things to keep outside the shell: login pages that must be top-level (some OAuth/SSO providers refuse to be framed; send those to the top window and come back to the shell), payment pages of third parties, and pages that set top.location. A page that navigates window.top leaves the shell and ends the call's audio.
With Call window opens as → Browser extension, the call engine runs inside the Callston extension for Google Chrome and Microsoft Edge, in a hidden background document. There is no window or panel to keep open, calls survive every page load, and the agent answers from the extension's toolbar button or from a desktop notification.
enableCalls() (inside a click) opens a short-lived tab that hands its one-time launch ticket to the extension and closes itself. If the extension is not installed, that tab offers the install link and a Use this tab instead button, so calls always work.https://callston.com/click2call/extension, allow the microphone on its setup page and pin its icon. The extension talks only to this Callston server.chrome-extension://<id>) only for the extension IDs of this installation. The extension stores its own engine credential in the browser profile, never in a URL, and receives the same projection as the window: hidden numbers stay hidden.POST https://callston.com/api/v1/click2call/calls
Authorization: Bearer c2ck_…
Idempotency-Key: crm-call-8842
Content-Type: application/json
{ "integration_id": "c2c_…",
"agent": { "type": "email", "value": "[email protected]" },
"phone_number": "+14155550101",
"contact_reference": "crm-8842", "display_name": "Alice Johnson", "display_company": "Northwind",
"metadata": { "ticket": 4411 } }
Response 202 Accepted — queued, not answered:
{ "ok": true, "request_id": "ci_…", "call_id": "ci_…", "status": "queued",
"display": { "name": "Alice Johnson", "company": "Northwind", "number": null, "contact_reference": "crm-8842" },
"expires_at": 1727612405, "server_time": 1727612345 }
What happens: Callston validates the integration, the agent (membership, assignment, enabled, outgoing allowed), the number (E.164, destination countries, do-not-call list), the account (status, funds, concurrency) and that the agent has a ready call window with a microphone; freezes the policy into an intent; the window claims it atomically and connects with nothing but the reference; the provider webhook consumes the intent once and dials the real destination. Double-clicks with the same Idempotency-Key return the same intent; the same key with a different body is rejected (409 idempotency_conflict). An intent that no window picks up within the expiry (default 60 s) becomes expired.
Errors (all { "ok": false, "error": "…", "message": "…" }): agent_offline (409, no live window), agent_not_ready (409, no microphone), agent_busy (409), outbound_not_allowed (403), invalid_phone_number (422), destination_not_allowed / destination_blocked (403), concurrency_limit (429), insufficient_balance (402), no_caller_id (422), idempotency_conflict (409).
Status and hangup:
GET https://callston.com/api/v1/click2call/calls/{request_id}
POST https://callston.com/api/v1/click2call/calls/{request_id}/hangup
duration is computed from the authoritative answered timestamp and the server clock, never from a browser timer.
Incoming calls follow the account's normal routing: number → IVR → department → queue for businesses; forwarding / dashboard ringing for personal accounts. An agent is routable through Click2Call when their integration and member are enabled, incoming is allowed, and their call window is live, registered, has a microphone and is set Available. The queue then rings the window (client:c2c_<engine>) with the existing ring strategy; busy, decline and no-answer follow the department's timeout, overflow, voicemail and callback rules. Membership is re-validated at accept time. One live window per member; a second window is refused while the first is on a call.
The window and the widget show the department, a safe caller label (a lead name when known) and the number according to the inbound visibility policy. Enabling incoming never enrolls the agent in campaign auto-dialing. Browsers cannot open the call window for an incoming call without a gesture: the agent enables calls once per browser session.
postMessage nudge. Keypad tones play at once in the widget, the window and the extension; the provider SDK's own delayed local tone is silenced. Widget controls (mute, availability) update the widget at once and are confirmed by the state poll. Measured in Chromium with 250 ms added to every server request (roughly shared hosting behind a CDN): widget keypad → call window ≈ 0.45 s median, mute and hang up ≈ 0.5 s. A missed heartbeat is not a hangup: a window is considered offline after 90 s and its legs are then ended deliberately.call.orphaned: true) with Hang up. Housekeeping runs the same checks on every cron pass.hangup_uncertain).timeLimit) from the platform cap, the integration cap and the affordable share of the wallet; concurrent calls each plan for their share.To, Biz, CallerId or the intent is refused before any legacy branch (fail closed). Provider signatures are verified against the account the intent belongs to.Rate limits: 300 requests/min per IP and 600/min per integration for server-key endpoints; 600/min per IP for widget endpoints. Exceeding them returns 429 rate_limited with Retry-After.
cURL — request a call:
curl -X POST https://callston.com/api/v1/click2call/calls \
-H "Authorization: Bearer $CALLSTON_SERVER_KEY" -H "Idempotency-Key: crm-8842-$(date +%s)" \
-H "Content-Type: application/json" \
-d '{"integration_id":"c2c_…","agent":{"type":"email","value":"[email protected]"},"phone_number":"+14155550101","contact_reference":"crm-8842","display_name":"Alice Johnson"}'
PHP backend — session and call endpoints:
<?php
// /callston/session — the employee is already authenticated by your app
$employee = current_user();
$res = callston('POST', '/api/v1/click2call/sessions', [
'integration_id' => CALLSTON_INTEGRATION_ID,
'agent' => ['type' => 'email', 'value' => $employee->email],
'origin' => 'https://crm.example.com',
]);
header('Content-Type: application/json');
echo json_encode(['widget_token' => $res['widget_token'] ?? null, 'expires_at' => $res['expires_at'] ?? null, 'error' => $res['error'] ?? null]);
// /callston/call — {contact_reference} from the page; the number stays on the server
$contact = contacts_find($_POST['contact_reference']);
$res = callston('POST', '/api/v1/click2call/calls', [
'integration_id' => CALLSTON_INTEGRATION_ID,
'agent' => ['type' => 'email', 'value' => $employee->email],
'phone_number' => $contact->phone, 'contact_reference' => $contact->id, 'display_name' => $contact->name,
], ['Idempotency-Key' => 'crm-' . $contact->id . '-' . time()]);
echo json_encode($res);
function callston(string $method, string $path, array $body, array $headers = []): array {
$ch = curl_init(CALLSTON_URL . $path);
$h = ['Authorization: Bearer ' . CALLSTON_SERVER_KEY, 'Content-Type: application/json'];
foreach ($headers as $k => $v) { $h[] = "$k: $v"; }
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => $method === 'POST' ? json_encode($body) : null, CURLOPT_TIMEOUT => 15]);
return json_decode((string) curl_exec($ch), true) ?: ['ok' => false, 'error' => 'unreachable'];
}
Vanilla multipage install: include the two script tags in your shared layout so every page has the widget; put the "Enable calls" button anywhere (it can also be the widget's own button).
SPA (React/Vue/etc.): call init() once when the app mounts after login; keep the returned widget in app state; call endSession() on logout and detach() when unmounting a preview.
Incoming: nothing to integrate — configure inbound routing in Callston and let agents set themselves Available in the widget or the window.
localhost).enableCalls() must be bound to a click. A tab (Call window opens as → New browser tab, or windowMode: 'tab') is never caught by popup blockers and is harder to close by mistake — agents can pin it. A popup stays small and on top. Users who block popups get an "Open in new tab" link either way.script-src from https://callston.com and connect-src to https://callston.com. The call window has its own CSP.allow="microphone" is needed there. In frame-shell mode the SDK creates the call panel with allow="microphone; autoplay" itself; your shell page must not deny microphone in its own Permissions-Policy header.php cron/run.php. Rollback: disable Click2Call in Admin → Settings → Click2Call (every endpoint refuses immediately); tables can stay.The widget shows the reason after Calls unavailable; your session endpoint receives the same code from POST /sessions.
| Widget shows | Cause | Fix |
|---|---|---|
| Browser console: blocked by CORS policy … No 'Access-Control-Allow-Origin' header | The browser asked Callston whether your page's origin may call the widget API and got no. The origin is not in Allowed origins of any active integration (or the integration is disabled). | Add the exact origin (or tick Allow all origins while testing) and make sure the integration is Active. Then hard-refresh the page — browsers cache a refused check for a few minutes. |
origin_not_allowed | The page's origin is not in the integration's Allowed origins, or your backend sent a different origin than the page's own. | In Callston add the exact origin — scheme + host + port, no path (open the page, run location.origin in the console and copy it). Your backend must pass on the origin the SDK posts, not a fixed or production value and never the full URL. Run the SDK in the top page, not in an iframe from another host. |
identifier_mode | agent.type is not the integration's Agent identifier mode — or the agent object is missing or shaped differently. | Send "agent": { "type": "email", "value": "…" } for email mode, or "type": "uuid" for UUID mode, exactly as in section 3. |
agent_unknown / agent_not_assigned / agent_disabled | The value names no active member (email: their Callston login email; UUID: their Callston UUID or the UUID mapped to them), the member is not covered by the integration, or their override switches calls off. | Fix the identifier, or the integration's coverage / per-agent settings. |
| Calls are not connected in this browser (and the Online switch is greyed out) | No call engine is connected for this agent: the call window was closed, the extension was never connected, or an old window is still on record (Reconnecting…). The switch drives the engine, so it cannot work. | Press Enable calls (in the widget, or your own button calling enableCalls() inside the click). In extension mode a short tab opens, hands over to the extension and closes; the extension then shows Connected. |
| This extension is not allowed on this server (in the hand-off tab) | The agent's extension is not this installation's (another build, or loaded unpacked). | Remove it and install it from https://callston.com/click2call/extension. |
| Waiting for the microphone… | The call window or the extension has no microphone permission. | Allow the microphone in the window, or on the extension's Settings & microphone page. |
| Requesting call…, then The call did not start | Your call endpoint answered "OK" but no call started: either Callston refused the request and your backend did not pass the refusal on, or Callston accepted it and the agent's call window never picked it up (it was closed, offline, or in another browser — the request expires after 60 s). | Return Callston's status and JSON unchanged (see section 4) — the widget then shows the real reason. Check the agent's widget shows Ready before calling, with the call window or extension connected in the same browser. |
| A popup never opens | enableCalls() ran outside a user click (after an await or an AJAX call), or popups are blocked. | Call it directly in the click handler; or use windowMode: 'tab' / Browser extension. |
| Method & path | Credential | Purpose |
|---|---|---|
POST /api/v1/click2call/sessions | server key | widget token for one agent + origin |
POST /api/v1/click2call/calls | server key | create a call intent (Idempotency-Key) |
GET /api/v1/click2call/calls/{id} | server key | intent status |
POST /api/v1/click2call/calls/{id}/hangup | server key | end the call |
GET /api/v1/click2call/widget/state | widget token | engine, call, policy, server time |
POST /api/v1/click2call/widget/launch-ticket | widget token | single-use ticket + window URL |
POST /api/v1/click2call/widget/commands | widget token | hangup, answer, decline, mute, unmute, dtmf, availability, focus (with request_id) |
POST /api/v1/click2call/widget/refresh | widget token | new widget token |
POST /api/v1/click2call/widget/revoke | widget token | end the widget session |
POST /api/v1/click2call/engine/pair | launch ticket | become the agent's engine (internal, used by the window) |
POST /api/v1/click2call/engine/voice-token, heartbeat, claim, hangup, close | engine token | internal, used by the window and the extension |
GET /click2call/window?embed=c2c_… | none | the call window for a frame shell; framable only by that integration's allowed origins |
GET /click2call/extension | none | agent page: install and set up the browser extension |
Full schemas: the OpenAPI document.