Skip to main content

4phone Client Calls API

This guide describes the production API for exporting calls and call recordings, configuring API keys, handling webhook events, and following the recommended integration workflow for your CRM, ERP, BI, or internal system.

1. Capabilities​

The integration supports:

  • REST API for initiating outbound calls from a CRM.
  • REST API for querying the call list, single call details, and call recording files.
  • Webhooks for real-time notifications when an inbound call starts ringing, when it completes and when its recording file becomes ready.

Recommended integration workflow:

  1. Receive new events via webhooks.
  2. Store each call in your database keyed by id.
  3. Download the recording after receiving CALL_RECORDING_READY.
  4. Periodically reconcile data via the REST API to recover any events missed while your webhook endpoint was unavailable.

2. Endpoints and Dashboard URLs​

REST API base URL:

https://api.4phone.uz/api/v1/external

API Keys management page:

https://4phone.uz/dashboard/settings/integrations/api-keys

Webhooks management page:

https://4phone.uz/dashboard/settings/integrations/webhooks

Webhook delivery history page:

https://4phone.uz/dashboard/settings/integrations/deliveries

3. Creating an API Key​

Users with the OWNER or ADMIN role can create and revoke API keys.

Setup steps:

  1. Open Settings.
  2. Navigate to Integrations.
  3. Open API Keys.
  4. Click Create key.
  5. Enter a descriptive name, such as Bitrix export or BI production.
  6. Select the calls:read scope.
  7. Select the calls:create scope if your CRM needs to initiate click-to-call outbound calls.
  8. Select the recordings:read scope if your integration needs to download recording files.
  9. Optionally restrict the key by department, IP addresses, and expiration date.
  10. Create the key and immediately save the displayed secret.

The full API key value is displayed only once. A lost secret cannot be recovered—you must revoke the old key and create a new one.

We recommend creating a separate API key for each external system so you can independently manage scopes, IP restrictions, and expiration dates.

3.1. Scopes​

Three permission scopes are used for the Calls API:

  • calls:read grants access to the list and details of completed calls.
  • calls:create grants permission to initiate outbound calls from a CRM.
  • recordings:read grants permission to check and download call recording files.

A key with only recordings:read can download a recording using a known call id, but cannot query the call list. Initiating a call from a CRM requires the separate calls:create scope; calls:read alone is not sufficient to start a call.

3.2. Department Restriction​

If the Department field is left empty, the API key has access to all eligible calls across your tenant.

If a department is selected, the API returns only calls involving extensions in that department. Filtering by an extension from another department returns an empty list, and requesting details for a call from another department returns 404.

Department restrictions also apply to downloading recordings and initiating outbound calls: attempting to start a call from an extension in another department returns 404.

3.3. IP Whitelist​

The IP whitelist field accepts:

  • a single IPv4 address, for example 45.147.253.34;
  • an IPv4 CIDR subnet, for example 45.147.253.32/27;
  • a single IPv6 address;
  • an IPv6 subnet in CIDR notation.

Multiple entries can be separated by commas, spaces, or newlines. If the list is empty, no IP restriction is applied.

Specify the public outbound IP address of the server making requests to the API. If the request originates from an unlisted IP, the API returns 403 with the error code api_key.ip_not_allowed.

3.4. Expiration and Revocation​

Setting an expiration date is optional, but recommended for temporary integrations and staging environments.

A revoked or expired key is immediately rejected. Revocation is permanent and irreversible. Both key creation and revocation are recorded in the platform audit log.

4. Request Authentication​

Recommended HTTP header:

X-Api-Key: cpbx_XXXXXXXX_SECRET

Bearer authentication is also supported:

Authorization: Bearer cpbx_XXXXXXXX_SECRET

Never pass the API key:

  • in URL query parameters;
  • in file names;
  • in client-side browser JavaScript;
  • in error messages or unredacted logs.

Store the API key in a secret manager or environment variable:

export FOURPHONE_API_KEY='cpbx_XXXXXXXX_SECRET'

Verify access:

curl -sS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls?limit=1"

5. Listing Calls​

GET /calls​

Full URL:

https://api.4phone.uz/api/v1/external/calls

Required scope: calls:read.

The API returns only completed inbound and outbound calls. Internal extension-to-extension calls are excluded from the client export.

Available call statuses:

  • ANSWERED
  • BUSY
  • NO_ANSWER
  • FAILED
  • CANCELLED

5.1. Query Filters​

Supported query parameters:

  • direction with value INBOUND or OUTBOUND;
  • status with a comma-separated list of statuses;
  • startedFrom with the start timestamp in RFC 3339 format (inclusive);
  • startedTo with the end timestamp in RFC 3339 format (exclusive);
  • phone with a full or partial phone number;
  • extensionNumber with an internal extension number;
  • hasRecording with value true or false;
  • limit from 1 to 100 (default is 50);
  • cursor taken from meta.nextCursor of the previous response.

In the phone filter, all non-digit characters are stripped automatically. The search matches against both the caller number and the destination number.

hasRecording=true means a recording record is linked to the call, though the audio file may still be processing. Always verify recording.available=true before attempting to download the file.

Example request for answered inbound calls:

curl -sS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls?direction=INBOUND&status=ANSWERED&startedFrom=2026-07-01T00%3A00%3A00%2B05%3A00&limit=50"

Example search by phone number:

curl -sS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls?phone=998901234567&limit=50"

5.2. Response Format​

{
"data": [
{
"id": "847cc73c-6aaf-4a5e-aea8-d106fae10c45",
"callId": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6",
"direction": "INBOUND",
"status": "ANSWERED",
"date": "23.07.2026 10:40:59",
"dateIso": "2026-07-23T05:40:59Z",
"timeZone": "Asia/Tashkent",
"from": "998901234567",
"to": "781139788",
"durationSeconds": 83,
"extensionNumber": "101",
"recording": {
"status": "AVAILABLE",
"available": true,
"fileName": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6.wav",
"contentType": "audio/wav",
"sizeBytes": 1382400,
"downloadUrl": "/api/v1/external/calls/847cc73c-6aaf-4a5e-aea8-d106fae10c45/recording"
}
}
],
"meta": {
"limit": 50,
"hasMore": false,
"nextCursor": null
}
}

5.3. Field Descriptions​

  • id is the call record ID. Use this ID to fetch call details or download the call recording.
  • callId is the technical telephony session ID.
  • direction is either INBOUND or OUTBOUND.
  • status is the final call disposition status.
  • date is the call timestamp formatted in the tenant's local time zone down to the second.
  • dateIso is the same timestamp in UTC (RFC 3339 format).
  • timeZone specifies the IANA time zone used to format date.
  • durationSeconds is the billable talk time in seconds. For unanswered calls, this value is 0.
  • extensionNumber is the employee's internal extension number, or null.

For an inbound call:

  • from contains the external caller's phone number without a leading +;
  • to contains the original DID number dialed by the caller.

For an outbound call:

  • from contains Last Name First Name Middle Name if all three name fields are populated on the extension's assigned user;
  • if the full name is incomplete, from contains the phone number;
  • to contains the external destination number without a leading +.

5.4. Recording Object​

If no recording was created for the call, recording is null.

Possible values for recording.status:

  • CAPTURED
  • ACCEPTED
  • UPLOADED
  • AVAILABLE
  • FAILED
  • ARCHIVED

Downloading is permitted only when:

{
"available": true
}

available=true is set for the AVAILABLE and ARCHIVED statuses. For all other statuses, downloadUrl is null.

5.5. Initiating an Outbound Call from CRM​

POST /calls​

Required scope: calls:create.

Use this endpoint to power a click-to-call button in your CRM. First, 4phone rings the employee's internal extension. Once the employee answers, 4phone dials the customer's external phone number using the tenant's configured outbound routes.

curl -sS \
-X POST \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"extensionNumber": "101",
"phoneNumber": "+998901234567"
}' \
"https://api.4phone.uz/api/v1/external/calls"

Request body fields:

  • extensionNumber: the employee's extension number (2 to 6 digits).
  • phoneNumber: the customer's external phone number. A leading +, spaces, parentheses, and hyphens are allowed. After normalization, the number must contain between 7 and 15 digits.

A successful request returns HTTP 202:

{
"callId": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6",
"jobId": "8f4a3b2c-1234-4567-89ab-1234567890ab",
"status": "INITIATED",
"extensionNumber": "101",
"phoneNumber": "998901234567"
}

callId is the technical telephony session ID. Once the call finishes, you can locate it in the call list by callId or receive its final outcome via the CALL_COMPLETED webhook.

The INITIATED status indicates only that the platform has accepted the request to start the call—it does not mean the employee or customer has answered yet. If the employee does not answer their extension, the customer is not dialed, and the completed call record will appear in history with a status of NO_ANSWER, BUSY, CANCELLED, or FAILED.

This endpoint has a dedicated rate limit of 30 call initiations per minute per API key. Your CRM UI should disable the call button until the API responds to prevent accidentally placing duplicate calls to the same customer.

6. Pagination​

Results are sorted from newest to oldest by call start time.

When meta.hasMore=true, pass meta.nextCursor unchanged in the next request:

curl -sS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls?limit=50&cursor=VALUE_FROM_NEXT_CURSOR"

The cursor is cryptographically bound to:

  • the tenant;
  • the API key's department restriction;
  • direction;
  • status;
  • date range (startedFrom / startedTo);
  • phone;
  • extensionNumber;
  • hasRecording.

You cannot change query filters between pages. If you change any filter, start a fresh request without cursor. Passing a modified or corrupted cursor returns 400.

For scheduled synchronization jobs, query a time window with a small overlap and deduplicate records by id. This prevents missed calls in the event of a transient network error.

7. Call Details​

GET /calls/{id}​

Required scope: calls:read.

curl -sS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls/$CALL_ID"

The response returns the same call object structure as an item in the data array of GET /calls.

The API returns 404 if:

  • the call does not exist;
  • the call belongs to another tenant;
  • the call belongs to a department outside the API key's scope;
  • the call is an internal extension-to-extension call;
  • the call has not yet reached a completed status.

Returning an identical 404 response across all these cases prevents leaking the existence of records belonging to other tenants or departments.

8. Downloading Call Recordings​

HEAD /calls/{id}/recording​

Checks recording availability and response headers without downloading the audio file body.

curl -sS -I \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
"https://api.4phone.uz/api/v1/external/calls/$CALL_ID/recording"

GET /calls/{id}/recording​

Downloads the recording audio file.

curl -fS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
-o call-recording.wav \
"https://api.4phone.uz/api/v1/external/calls/$CALL_ID/recording"

Required scope for both methods: recordings:read.

8.1. Dual-Channel (Stereo) WAV Files​

When stereo recording is enabled:

  • the recording file remains in WAV format;
  • the first channel contains the inbound audio stream from the originating leg;
  • the second channel contains the outbound audio stream toward the originating leg;
  • for an inbound call, channel 1 is typically the customer and channel 2 is the operator;
  • for an outbound call, channel 1 is typically the operator and channel 2 is the customer;
  • stereo recording continues to work across call queues and call transfers.

Older recordings may remain single-channel (mono). Your integration should read the channel count directly from the WAV file header rather than inferring it from the call date or file name.

GET streams the original file without transcoding or downmixing channels, and HEAD returns its exact metadata. The endpoint URL, recordings:read scope, HTTP Range requests, and all other API rules remain identical for dual-channel recordings. The recording JSON object does not include a channel count field; inspect the downloaded WAV header to determine the number of channels.

Example command to inspect a WAV file with ffprobe:

ffprobe -v error \
-select_streams a:0 \
-show_entries stream=codec_name,sample_rate,channels,channel_layout \
-of default=noprint_wrappers=1 \
call-recording.wav

A dual-channel WAV file is roughly twice the size of a single-channel recording of the same duration.

The response includes the following headers:

  • Content-Type with the audio MIME type;
  • Content-Disposition with a sanitized file name;
  • Content-Length, when the file size is known;
  • Accept-Ranges: bytes;
  • Cache-Control: private, no-store.

Partial byte-range requests are supported:

curl -fS \
-H "X-Api-Key: $FOURPHONE_API_KEY" \
-H "Range: bytes=0-1048575" \
-o first-megabyte.bin \
"https://api.4phone.uz/api/v1/external/calls/$CALL_ID/recording"

Partial responses return HTTP 206 Partial Content along with a Content-Range header.

9. Call Webhooks​

While the REST API is ideal for batch exports and reconciliation, configure a webhook to receive call notifications in real time.

9.1. Creating a Webhook​

  1. Open Settings.
  2. Navigate to Integrations.
  3. Open Webhooks.
  4. Click Add webhook.
  5. Enter a descriptive name.
  6. Enter the public HTTPS URL of your webhook receiver endpoint.
  7. Select CALL_START if you want to show a contact card the moment an inbound call arrives.
  8. Select CALL_COMPLETED.
  9. Select CALL_RECORDING_READY if you also need recording readiness events.
  10. Leave the secret field blank to let the platform generate a strong secret automatically.
  11. Save the displayed signing secret in your system's secret manager.

Your webhook URL must:

  • use HTTPS;
  • not contain embedded credentials (username:password);
  • use a public DNS hostname or public IP address;
  • resolve exclusively to public IP addresses;
  • be reachable from the public internet.

Localhost, loopback, link-local, and private network ranges are blocked to protect against SSRF. The URL policy is validated both when saving the webhook and before every delivery attempt.

A custom signing secret must be between 16 and 256 characters long. Once saved, the secret is never shown again.

If a signing secret is lost or compromised, click Rotate secret. After rotating the secret, update your webhook receiver immediately with the new value.

9.2. CALL_START​

This event is created at the moment an inbound call starts ringing on an employee's phone. It is the earliest point at which your system can show a contact card for the number in data.from, without waiting for the call to be answered or to finish.

{
"id": "5d0c6a52-8d2b-4a39-9f0e-0a6b7b0f4c11",
"event": "CALL_START",
"data": {
"schemaVersion": 1,
"callId": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6",
"direction": "INBOUND",
"status": "RINGING",
"date": "23.07.2026 10:40:59",
"dateIso": "2026-07-23T05:40:59Z",
"timeZone": "Asia/Tashkent",
"from": "998901234567",
"to": "781139788",
"extensionNumber": "101"
},
"sentAt": "2026-07-23T05:40:59.000Z",
"timestamp": 1784770859
}

Things to know:

  • The event is created only for inbound calls from customers. Outbound and internal calls do not create it.
  • data.from is the customer's number, data.to is the number the customer dialed, and data.extensionNumber is the internal extension that is ringing. Use it to show the card to the right operator.
  • One event is sent for every ringing extension. If a call reaches a queue or group that rings several employees at once, you receive several events with the same data.callId and different data.extensionNumber values.
  • If the call first goes through a voice menu or waits in a queue, the event is sent when the call is offered to a specific employee.
  • The event is sent once per data.callId and data.extensionNumber pair.
  • The call record does not exist in the history yet, so the event has no data.id, durationSeconds or recording. To correlate it with CALL_COMPLETED and CALL_RECORDING_READY, use data.callId: it is the same in all three events.
  • Delivery uses automatic retries, so event order is not guaranteed. If CALL_START arrives after CALL_COMPLETED with the same data.callId, do not show the card.
  • Your handler should respond quickly (within 10 seconds), otherwise the card appears with a delay. Perform heavy work after responding.

The event requires a webhook with a secret: the signature and delivery format are the same as for the other call events.

9.3. CALL_COMPLETED​

This event is triggered as soon as CDR processing finishes for an inbound or outbound call. Internal calls do not trigger webhooks.

If the call recording is still being processed when the call ends, the CALL_COMPLETED payload includes the call record with:

{
"recording": {
"available": false,
"downloadUrl": null
}
}

9.4. CALL_RECORDING_READY​

This event is sent separately once the recording file reaches the AVAILABLE or ARCHIVED status. Pending recordings are checked every 30 seconds.

The event carries the same data.id as the corresponding CALL_COMPLETED event, along with:

{
"recording": {
"available": true,
"downloadUrl": "/api/v1/external/calls/847cc73c-6aaf-4a5e-aea8-d106fae10c45/recording"
}
}

If recording processing ends with the FAILED status, CALL_RECORDING_READY is not sent.

9.5. Webhook Request Format​

POST /your/4phone/webhook
Content-Type: application/json
X-Webhook-Id: 8f4a3b2c-1234-4567-89ab-1234567890ab
X-Webhook-Timestamp: 1784770859
X-Webhook-Event: CALL_COMPLETED
X-Webhook-Signature: v1,abcdef1234567890
{
"id": "8f4a3b2c-1234-4567-89ab-1234567890ab",
"event": "CALL_COMPLETED",
"data": {
"schemaVersion": 1,
"id": "847cc73c-6aaf-4a5e-aea8-d106fae10c45",
"callId": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6",
"direction": "INBOUND",
"status": "ANSWERED",
"date": "23.07.2026 10:40:59",
"dateIso": "2026-07-23T05:40:59Z",
"timeZone": "Asia/Tashkent",
"from": "998901234567",
"to": "781139788",
"durationSeconds": 83,
"extensionNumber": "101",
"recording": {
"status": "CAPTURED",
"available": false,
"fileName": "17ed2d85-a64a-4ce0-9151-a4dc57505dc6.wav",
"contentType": "audio/wav",
"sizeBytes": null,
"downloadUrl": null
}
},
"sentAt": "2026-07-23T05:40:59.000Z",
"timestamp": 1784770859
}

The root id is the unique webhook event ID. data.id is the call record ID.

9.6. Verifying the HMAC Signature​

The signature is computed over the exact raw bytes of the HTTP request body:

signedContent = X-Webhook-Id + "." + X-Webhook-Timestamp + "." + rawBody
signature = "v1," + HMAC_SHA256_HEX(secret, signedContent)

Always verify the signature using the raw request body before parsing JSON. Re-serializing a parsed JSON object may alter whitespace or key ordering and cause signature verification to fail.

Node.js and Express example:

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.FOURPHONE_WEBHOOK_SECRET;

app.post(
"/4phone/webhook",
express.raw({ type: "application/json" }),
(request, response) => {
const messageId = String(request.header("X-Webhook-Id") || "");
const timestamp = String(request.header("X-Webhook-Timestamp") || "");
const received = String(request.header("X-Webhook-Signature") || "");
const rawBody = request.body;
const signedContent = Buffer.concat([
Buffer.from(`${messageId}.${timestamp}.`),
rawBody,
]);
const expected =
"v1," +
crypto.createHmac("sha256", secret).update(signedContent).digest("hex");

const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!valid || !Number.isFinite(age) || age > 300) {
return response.status(401).json({ accepted: false });
}

const payload = JSON.parse(rawBody.toString("utf8"));

// First check whether payload.id has already been processed in your database.
// Then enqueue the event for asynchronous processing.

return response.status(202).json({ accepted: true });
},
);

The exact same verification formula applies in Python, PHP, Go, or any other language: pass the raw HTTP body bytes directly into HMAC-SHA256 without re-serializing JSON.

9.7. Idempotency​

4phone uses at-least-once webhook delivery. Your receiver must handle duplicate deliveries gracefully.

Store the root id (or X-Webhook-Id header) in a processed-events table. If an incoming event ID has already been processed, return a 2xx HTTP response immediately without repeating the business operation.

Similarly, use data.id as the unique key for call records in your database. Note that CALL_COMPLETED and CALL_RECORDING_READY for the same call have different webhook id values, but share the same data.id. CALL_START is sent before the call record exists and has no data.id: correlate it with the other events by data.callId.

9.8. Responses and Automatic Retries​

A delivery is considered successful when your endpoint responds with an HTTP status code between 200 and 299.

Your webhook handler should:

  • verify the HMAC signature;
  • validate the timestamp freshness;
  • check idempotency;
  • persist the event or push it to an internal queue quickly;
  • respond within 10 seconds.

If a network error, timeout, or non-2xx HTTP status occurs, the platform attempts up to 8 deliveries. Retry backoff intervals after failed attempts are: 30, 60, 120, 240, 480, 960, and 1920 seconds.

On each retry attempt, timestamp, sentAt, and X-Webhook-Signature are regenerated with the current retry time, while X-Webhook-Id (and the root id) remains unchanged.

10. Delivery History​

Under Settings -> Integrations -> Delivery History, you can inspect:

  • event type;
  • target webhook;
  • delivery status;
  • last HTTP status code;
  • number of delivery attempts;
  • last error message;
  • timestamps of recent attempts.

Delivery statuses:

  • PENDING — queued and waiting to be sent;
  • PROCESSING — delivery attempt currently in progress;
  • DELIVERED — accepted by your endpoint (2xx);
  • FAILED — attempt failed, waiting for the next scheduled retry;
  • DLQ — all automatic retry attempts exhausted.

Manual Replay is available for deliveries in FAILED or DLQ status.

11. Rate Limits​

API key rate limits are tracked per endpoint category within a 60-second window:

  • up to 300 requests per minute for standard read endpoints;
  • up to 120 requests per minute for GET or HEAD recording endpoints;
  • up to 30 call initiations per minute for POST /calls.

Responses include rate-limit headers:

X-RateLimit-Limit
X-RateLimit-Remaining
X-RateLimit-Reset

When a limit is exceeded, the API returns HTTP 429 along with Retry-After: 60.

Your client should handle 429 responses, respect the Retry-After header, and apply exponential backoff before retrying.

12. Error Reference​

Errors are returned in JSON format:

{
"statusCode": 403,
"timestamp": "2026-07-30T12:41:59.676Z",
"path": "/api/v1/external/calls?limit=1",
"code": "api_key.scope_missing",
"message": "Missing API key scopes: calls:read"
}

Common HTTP status codes and error codes:

  • 400 calls.invalid_date_range — invalid date range (startedFrom / startedTo);
  • 400 calls.invalid_phone — phone filter contains no digits;
  • 400 calls.invalid_cursor — malformed or corrupted pagination cursor;
  • 400 calls.cursor_filter_mismatch — query filters changed between paginated requests;
  • 401 api_key.required — missing API key header;
  • 401 api_key.invalid — invalid or revoked API key;
  • 401 api_key.expired — expired API key;
  • 403 api_key.ip_not_allowed — client IP is not in the key's IP whitelist;
  • 403 api_key.scope_missing — API key lacks the required scope for this endpoint;
  • 400 calls.invalid_phone (on POST /calls) — invalid destination phone number;
  • 404 calls.not_found — call does not exist or is not accessible;
  • 404 calls.extension_not_found — extension was not found, is disabled, or belongs to a department outside the key's scope;
  • 404 recordings.not_found — recording does not exist or failed processing;
  • 409 calls.concurrent_limit_exceeded — tenant concurrent call limit reached;
  • 409 recordings.not_ready — recording file is still being processed;
  • 422 calls.route_not_found — no outbound route matches the destination number;
  • 503 calls.telephony_unavailable — telephony service is temporarily unavailable;
  • 416 recordings.invalid_range — requested byte Range is invalid;
  • 429 rate_limit.exceeded — API key rate limit exceeded.

13. Scheduled Synchronization Example​

Node.js example:

const baseUrl = "https://api.4phone.uz/api/v1/external";
const apiKey = process.env.FOURPHONE_API_KEY;

async function request(path) {
const response = await fetch(`${baseUrl}${path}`, {
headers: {
"X-Api-Key": apiKey,
},
});

if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
`4phone API ${response.status}: ${error.code || error.message || "unknown"}`,
);
}

return response.json();
}

async function loadCalls(startedFrom) {
const calls = [];
let cursor = null;

do {
const query = new URLSearchParams({
startedFrom,
limit: "100",
});

if (cursor) {
query.set("cursor", cursor);
}

const page = await request(`/calls?${query.toString()}`);
calls.push(...page.data);
cursor = page.meta.nextCursor;
} while (cursor);

return calls;
}

Store id as a unique key in your database. After each successful sync run, advance your checkpoint to the latest dateIso while keeping a small time overlap on the next query.

14. Troubleshooting​

API returns 401​

Check that:

  • the key is passed in the X-Api-Key or Authorization: Bearer header;
  • the secret value was copied in full without extra whitespace;
  • the key has not been revoked;
  • the key has not passed its expiration date.

API returns 403​

Check that:

  • the calls:read scope is enabled on the key;
  • the calls:create scope is enabled when initiating calls;
  • the recordings:read scope is enabled when accessing recordings;
  • the IP whitelist includes the public egress IP of your server.

Call list is empty​

Check that:

  • the date range and time zone offset in startedFrom and startedTo are correct;
  • the direction and status filters match the expected calls;
  • the key's department restriction includes the target extensions;
  • the extensionNumber filter is accurate;
  • the calls you are looking for are external (internal calls are not exported).

Recording endpoint returns 409​

The audio file is still being processed. Wait for the CALL_RECORDING_READY webhook or retry the request with exponential backoff.

Initiating a call returns 404​

Check that:

  • the internal extension exists and is active;
  • the API key is not restricted to a different department;
  • the internal extension was passed in extensionNumber rather than the customer's phone number.

Initiating a call returns 202, but the customer never receives a call​

HTTP 202 confirms only that the call job was queued. Check the call outcome in history by callId: if the employee did not answer their extension, the external customer number is never dialed. If the employee did answer, inspect the final call status, outbound routing rules, and SIP trunk availability.

Webhook cannot be created​

Check that:

  • the webhook is marked active;
  • at least one event type is selected;
  • the endpoint URL uses https://;
  • the hostname resolves only to public IP addresses;
  • the signing secret meets length requirements (16–256 characters).

Webhook delivery is in FAILED or DLQ status​

Open Delivery History and inspect:

  • the HTTP response status code;
  • the last error message;
  • the endpoint response time;
  • your server's TLS certificate chain validity;
  • whether your endpoint responds within 10 seconds.

Once the issue is resolved, click Replay to redeliver the event.

HMAC signature verification fails​

Check that:

  • you are hashing the exact raw request body bytes before JSON parsing;
  • you are using the signing secret belonging to that specific webhook;
  • the signed string concatenates X-Webhook-Id, X-Webhook-Timestamp, and the body separated by dots (.);
  • the expected signature includes the v1, prefix before the lowercase hex digest;
  • your server clock is synchronized via NTP;
  • your receiver has been updated if the webhook secret was recently rotated.

15. Security Best Practices​

  • Create a separate API key for each integrated system.
  • Grant only the minimum required scopes.
  • Restrict production API keys to your server's public IP addresses.
  • Set expiration dates on temporary or staging keys.
  • Never commit API keys or webhook secrets to Git.
  • Never pass secrets in URL query parameters.
  • Redact authorization headers from application and proxy logs.
  • Verify webhook HMAC signatures before parsing JSON payloads.
  • Enforce a maximum timestamp tolerance window of 5 minutes.
  • Implement idempotency using the webhook id.
  • Immediately revoke compromised API keys or rotate compromised webhook secrets.

16. Versioning and Compatibility​

Current payload schema version:

{
"schemaVersion": 1
}

New optional fields may be added to responses and webhook payloads without changing the API URL version. Your client must ignore unknown fields rather than rejecting the payload. Breaking changes will only be introduced under a new API version path.