Ringline Telephony API
Receive call events as they happen, pull the extension directory, download recordings, and let Ringline show your customer's data to the operator while the phone is still ringing.
https://app.ringline.uz — one host for every endpoint, HTTPS only.
Machine-readable spec: openapi.json (OpenAPI 3.0, import it into Postman or generate a client).
What you can build
Ringline is a cloud PBX. Your system and ours meet in four places, and you can use any subset of them:
| Capability | Direction | How |
|---|---|---|
| Call events ringing, answered, completed, missed |
Ringline → you | Webhooks signed with HMAC-SHA256. See Event types. |
| Call recordings | you → Ringline | Each finished call carries a download URL. See recordings. |
| Extension directory | you → Ringline | Map your users to operators, and read who is available right now. |
| Caller lookup customer card on the operator's screen |
Ringline → you | You expose one endpoint; we call it on every incoming call. See Caller lookup. |
Quick start
-
Get a key. We issue it and send it to you out of band. It looks like
rl_live_…, roughly 51 characters. -
Check it works.
Acurl -H "Authorization: Bearer rl_live_YOUR_KEY" \ https://app.ringline.uz/api/v1/account200with adomain_namemeans you are connected. -
Subscribe your endpoint. The response hands you the
signing_secret— store it, it is shown only here.curl -X POST https://app.ringline.uz/api/v1/webhooks \ -H "Authorization: Bearer rl_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://crm.example.de/hooks/ringline","events":["*"]}' -
Prove delivery without waiting for a real call.
You get back the exact HTTP status and body your server replied with, so you can see your own signature check pass or fail.curl -X POST https://app.ringline.uz/api/v1/webhooks/test \ -H "Authorization: Bearer rl_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"event_type":"call.completed"}' -
Handle events. Verify the signature, then act on
call.*. Reply2xxwithin 10 seconds.
Authentication
Every request carries a permanent API key as a bearer token:
Authorization: Bearer rl_live_4Vb9esauzSwtlt1js-Ati4KV0Nwtp6QirYeGkFgSzU4
There is no OAuth flow, no refresh and no expiry. A key is valid until it is revoked in the Ringline panel, and revocation takes effect on the very next request.
We store only the SHA-256 hash of the key, so a lost key cannot be recovered — it gets revoked and a new one issued. Treat it like a password: server side only, never in a browser, a mobile app or a repository.
Scopes
Each key carries a set of scopes. GET /account tells you which ones you have; an endpoint
you lack the scope for answers 403 insufficient_scope.
| Scope | Needed for |
|---|---|
calls.read | Receiving call events |
recordings.read | GET /api/v1/recordings/{call_uuid} |
operators.read | GET /api/v1/extensions |
webhooks.manage | Subscribing, unsubscribing, sending a test event |
Sandbox
Development happens against a dedicated tenant called sandbox, on the same host as
production. It holds two extensions (1001, 1002) and two operators, and it carries no live traffic, so
nothing you do there can affect a real customer.
Ask us for the sandbox key. With it you can exercise everything except real calls: validate the key,
read the directory, subscribe, and fire sample events with
POST /webhooks/test as often as you like.
Endpoints
Validates the key and describes the tenant behind it. This is the right endpoint for a "test connection" button.
{
"domain_uuid": "0e0d5944-a54e-4af6-ab27-14f0757851df",
"domain_name": "sandbox",
"key": {
"id": 5,
"name": "Sandbox - integration partners",
"prefix": "rl_live_KCYp",
"scopes": ["calls.read", "recordings.read", "operators.read", "webhooks.manage"],
"created_at": "2026-11-09 15:20:00+05",
"last_used_at": "2026-11-09 15:29:24+05"
},
"webhook": null,
"extensions": [
{
"number": "1001",
"label": "Operator: Sandbox One",
"enabled": true,
"operator_id": 50,
"operator_name": "Sandbox One",
"operator_email": "op1@sandbox.local",
"medflow_user_id": null
}
]
}
domain_uuid identifies the tenant and appears on every event, so a multi-tenant
integration can route incoming events by it. Ignore medflow_user_id; it belongs to a
different integration.
The directory again, plus live availability. Use operator_email as the stable key when
matching your own users to operators — extension numbers get reassigned, emails do not.
{
"extensions": [
{
"number": "1001",
"label": "Operator: Sandbox One",
"enabled": true,
"operator_id": 50,
"operator_name": "Sandbox One",
"operator_email": "op1@sandbox.local",
"presence_mode": "full",
"inbound_enabled": true,
"outbound_enabled": false
}
]
}
| Field | Meaning |
|---|---|
presence_mode | full takes calls; outgoing_only and offline are never rung. |
inbound_enabled | Administrative switch. False means this operator is excluded from incoming calls. |
enabled | Whether the extension itself exists in the PBX. |
An operator who is not full and inbound-enabled will never show up in a
call.ringing event. That is the quickest explanation for "why didn't this person get the call".
{ "subscribed": true, "url": "https://crm.example.de/hooks/ringline", "events": ["*"] }
The signing secret is deliberately not returned here. It is revealed once, when you subscribe.
Registers where events go, and returns the secret used to sign them.
// request
{ "url": "https://crm.example.de/hooks/ringline", "events": ["*"] }
// response
{
"subscribed": true,
"url": "https://crm.example.de/hooks/ringline",
"events": ["*"],
"signing_secret": "v6tZj-XCkc4pMOCL05vthvT8OTRmoYAP2AN_WdjI7-ulG15HSnF3y2l5C_BLFZuU"
}
One subscription per key: posting again replaces the URL and the event list. The secret belongs to the key and does not change — rotating it means issuing a new key.
Requirements on the URL, each enforced both at registration and again on every single delivery:
https://only, at most 2048 characters;- must resolve to a public address — loopback,
10/8,172.16/12,192.168/16, CGNAT and cloud-metadata ranges are refused; - no credentials in the URL;
- redirects are not followed, so give us the final address.
Omitting events is the same as ["*"]. An unknown event type is a
422 rather than a silent no-op.
Stops delivery. The key stays valid and the secret is unchanged, so you can resubscribe later without re-keying anything.
Sends one signed event to your URL now and reports what your server answered. This is how you validate your receiver before the tenant has made a single call.
// request (optional body; default is a minimal "ping")
{ "event_type": "call.completed" }
// response
{
"delivered": true,
"url": "https://crm.example.de/hooks/ringline",
"event_type": "call.completed",
"http_status": 200,
"response_body": "{\"received\":true}",
"sent_payload": { "...": "exactly what was signed and sent" }
}
| Field | Meaning |
|---|---|
delivered | True when your server answered 2xx. |
http_status | Your status code. 0 means the connection itself failed — DNS, TLS or timeout. |
response_body | First 2000 characters of your reply, which is usually enough to see your own error message. |
"test": true at the top level, and the sample call uses
+998000000000 / +998000000001, numbers that cannot be assigned to a real
subscriber. Nothing is written to the delivery log and nothing is retried.
Streams the audio of a finished call. call_uuid is call.call_uuid from the
event; the ready-made link is in call.recording.url.
curl -H "Authorization: Bearer rl_live_YOUR_KEY" \
-o call.mp3 \
https://app.ringline.uz/api/v1/recordings/71dc4322-a21b-48d7-8adf-b743b4ac4e19
The response is raw audio, not JSON:
HTTP/1.1 200 OK
Content-Type: audio/mpeg
Content-Disposition: attachment; filename="71dc4322-a21b-48d7-8adf-b743b4ac4e19.mp3"
Accept-Ranges: bytes
- Container varies. Today recordings are MP3; older ones are WAV. Read
Content-Typeorcall.recording.formatinstead of assuming an extension. - Range requests work —
Range: bytes=0-65535answers206, so an interrupted download can resume. - Tenant isolation. A
call_uuidfrom another tenant answers404, exactly like one that does not exist.
404. If your product needs recordings long-term, download them when
the call.completed event arrives and store them on your side.
Event types
Four events, all about calls. Subscribe to ["*"] for everything, or name the ones you want.
| Event | When | Typical use |
|---|---|---|
call.ringing |
The phone starts ringing. | Pop the customer card on screen; start a timer. |
call.answered |
An operator picks up. | Mark the conversation as started; note who took it. |
call.completed |
A call that was answered has ended. | Write the call to history; fetch the recording. |
call.missed |
The call ended without an operator answering. | Create a callback task. |
call.completed. There is no operator-presence event on this channel either. Subscribing to
anything outside the list above is rejected with 422 rather than accepted and never sent.
Group calls produce branches
When an incoming call rings several operators at once, you receive the call itself plus one
branch per operator leg. Branches carry is_branch: true and
parent_call_uuid pointing at the parent call.
If you want one record per conversation — the usual case — ignore every event with
is_branch: true. Process branches only if you specifically want to know which
individual phones rang.
Payload reference
Every event has the same envelope, with the fields that are not known yet set to null:
{
"event_id": "evt_71dc4322-a21b-48d7-8adf-b743b4ac4e19_callcompleted_k5",
"event_type": "call.completed",
"occurred_at": "2026-11-09T15:24:45+05:00",
"domain_uuid": "0e0d5944-a54e-4af6-ab27-14f0757851df",
"call": {
"call_uuid": "71dc4322-a21b-48d7-8adf-b743b4ac4e19",
"is_branch": false,
"parent_call_uuid": null,
"direction": "inbound",
"from_number": "+4930123456",
"to_number": "+998781136247",
"customer_number": "+4930123456",
"status": "completed",
"answered_by_operator": true,
"ringing_at": "2026-11-09T15:24:21+05:00",
"answered_at": "2026-11-09T15:24:26+05:00",
"ended_at": "2026-11-09T15:24:45+05:00",
"wait_seconds": 5,
"talk_seconds": 19,
"total_seconds": 24,
"operator": {
"extension": "1001",
"name": "Sandbox One",
"email": "op1@sandbox.local"
},
"recording": {
"available": true,
"url": "https://app.ringline.uz/api/v1/recordings/71dc4322-a21b-48d7-8adf-b743b4ac4e19",
"format": "mp3",
"duration_seconds": 19
},
"hangup_cause": "NORMAL_CLEARING"
}
}
| Field | Notes |
|---|---|
event_id | Unique and stable per (call, event type). Use it as an idempotency key — a retry repeats the same event_id. |
occurred_at | ISO 8601 with the tenant's offset (Uzbekistan is +05:00, no daylight saving). |
domain_uuid | Which tenant this call belongs to. |
customer_number | Match your records on this field. It is the number of the person outside the company regardless of direction, so you do not have to branch on direction. |
from_number / to_number | Raw calling and dialled numbers as the PBX saw them. |
answered_by_operator | True only when a human picked up. An IVR or voicemail answering does not count, and then answered_at is null and talk_seconds is 0. |
wait_seconds | Ring time before pickup — your queue-performance number. |
talk_seconds | Conversation length. Zero when nobody answered. |
total_seconds | Whole call including ring time. |
operator | Who handled it. Null on an unanswered group call — naming one of the operators who simply did not pick up would be misleading. |
recording.available | False when there is no recording, and also for files under 0.5 s, which are silence artefacts rather than conversations. |
hangup_cause | FreeSWITCH/SIP cause: NORMAL_CLEARING, USER_BUSY, NO_ANSWER, ORIGINATOR_CANCEL, … |
USER_BUSY). There is no way to tell a deliberate refusal from an engaged phone, so do not
build logic that depends on the difference.
Verifying the signature
Every delivery carries these headers:
X-Ringline-Event: call.completed
X-Ringline-Timestamp: 1794219829
X-Ringline-Signature: t=1794219829,v1=1f6b1093b401382ef7bae0a324e6d1bb2bb9a5fa9ae7bd86fb7366cb45160ab8
User-Agent: Ringline-Webhook/1.0
The signature is HMAC-SHA256 over the string "<t>.<raw request body>",
keyed with your signing_secret, hex-encoded.
<?php
$secret = getenv('RINGLINE_SIGNING_SECRET');
$raw = file_get_contents('php://input'); // raw bytes, not parsed
$header = $_SERVER['HTTP_X_RINGLINE_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = array_pad(explode('=', trim($p), 2), 2, '');
$parts[$k] = $v;
}
$ts = (int) ($parts['t'] ?? 0);
if (abs(time() - $ts) > 300) { // replay window
http_response_code(400);
exit('stale timestamp');
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '')) { // constant-time compare
http_response_code(401);
exit('bad signature');
}
$event = json_decode($raw, true);
// ... handle $event, then answer 2xx
http_response_code(200);
echo '{"received":true}';
import hashlib, hmac, time
from flask import Flask, request
app = Flask(__name__)
SECRET = b"your-signing-secret"
@app.post("/hooks/ringline")
def ringline():
raw = request.get_data() # raw bytes, not request.json
header = request.headers.get("X-Ringline-Signature", "")
parts = dict(
p.strip().split("=", 1) for p in header.split(",") if "=" in p
)
ts = int(parts.get("t", 0))
if abs(time.time() - ts) > 300: # replay window
return "stale timestamp", 400
expected = hmac.new(
SECRET, f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
return "bad signature", 401
event = request.get_json()
# ... handle event
return {"received": True}, 200
// ASP.NET Core minimal API
app.MapPost("/hooks/ringline", async (HttpRequest req) =>
{
using var reader = new StreamReader(req.Body);
var raw = await reader.ReadToEndAsync(); // raw body
var header = req.Headers["X-Ringline-Signature"].ToString();
var parts = header.Split(',')
.Select(p => p.Trim().Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
var ts = long.Parse(parts.GetValueOrDefault("t", "0"));
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - ts) > 300)
return Results.BadRequest("stale timestamp");
var secret = Encoding.UTF8.GetBytes(
Environment.GetEnvironmentVariable("RINGLINE_SIGNING_SECRET")!);
using var hmac = new HMACSHA256(secret);
var computed = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{raw}"))
).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(parts.GetValueOrDefault("v1", ""))))
return Results.Unauthorized();
// ... handle the event
return Results.Ok(new { received = true });
});
const crypto = require('crypto');
const express = require('express');
const app = express();
// raw body is required — express.json() would discard it
app.post('/hooks/ringline',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Ringline-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map(p => p.trim().split('='))
);
const ts = Number(parts.t || 0);
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(400).send('stale timestamp');
}
const expected = crypto
.createHmac('sha256', process.env.RINGLINE_SIGNING_SECRET)
.update(`${ts}.`)
.update(req.body) // Buffer, untouched
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1 || '')
);
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event
res.json({ received: true });
});
Delivery and retries
| Property | Value |
|---|---|
| Method | POST, Content-Type: application/json; charset=utf-8 |
| Expected reply | Any 2xx, within 10 seconds |
| First attempt | Immediately, from the PBX event daemon |
| Retries | After 1 min, 5 min, 30 min, 2 h, then once every 24 h |
| Attempt limit | 10, after which the event is abandoned |
| 4xx from you | Treated as final: logged, not retried |
| 5xx, timeout, TLS or DNS failure | Retried on the schedule above |
| Ordering | Not guaranteed. A retried ringing can arrive after completed. |
| Redirects | Not followed |
Practical consequences for your receiver:
- Be idempotent. Deduplicate on
event_id. Deliveries can repeat if your2xxwas lost on the way back. - Answer first, work later. Queue the event and reply
2xximmediately; ten seconds is not much if you call other services inline. - Do not order by arrival. Order by
occurred_at, or treatcall.completedas the authoritative record of the call. - Return 4xx only for genuinely bad requests. A 4xx means "never send this again". If you are briefly broken, answer 5xx so the event comes back.
Caller lookup in your CRM
The reverse direction: when a call comes in, Ringline queries your system and shows the result to the operator before they pick up — the customer's name, their membership, their next booking.
You expose one HTTP endpoint; we configure the request and the field mapping on our side, per tenant. Nothing needs to be deployed on our end for a new CRM, so this can be set up in minutes once your endpoint exists.
What we send
Method GET or POST, your own headers (an API token, typically), and your own
body template. These placeholders get substituted:
| Placeholder | Expands to | Example |
|---|---|---|
{phone} | The number exactly as the PBX saw it | +4930123456 |
{phone_e164} | Normalised to E.164 with a leading + | +998901234567 |
{phone_local} | Digits only, no + or separators | 998901234567 |
{domain_uuid} | The tenant, useful if you serve several of our customers | 0e0d5944-… |
A nine-digit Uzbek number is expanded to +998…; anything else is passed through with a
leading +, so international callers arrive intact.
Example of a configured POST body:
POST https://crm.example.de/api/phone-lookup
Authorization: Bearer YOUR_CRM_TOKEN
Content-Type: application/json
{ "msisdn": "{phone_e164}", "tenant": "{domain_uuid}" }
With GET and no placeholder in the URL, we append ?phone=… ourselves.
What you return
Any JSON shape you already have. We pick values out of it with simple paths —
$.customer.name, $.bookings[0].area — and map them onto a five-slot card the
softphone renders as-is:
| Card slot | Shown as | Good content |
|---|---|---|
title | Headline | Customer name |
subtitle | Second line | Membership level, segment |
info_1 | Detail line | Next booking: date and area |
info_2 | Detail line | Outstanding balance, note, visit count |
url | Clickable link | Deep link to the customer in your CRM |
Given this response:
{
"success": true,
"customer": {
"id": 8821,
"name": "Klaus Berger",
"membership": { "level": "Gold", "valid_until": "2027-03-01" },
"bookings": [
{ "date": "2026-11-12", "area": "Sauna Panorama", "guests": 2 }
],
"profile_url": "https://crm.example.de/customers/8821"
}
}
the mapping would be:
title = $.customer.name → "Klaus Berger"
subtitle = $.customer.membership.level → "Gold"
info_1 = $.customer.bookings[0].area → "Sauna Panorama"
info_2 = $.customer.bookings[0].date → "2026-11-12"
url = $.customer.profile_url → link
found = $.success → false means "no such customer"
Supported path syntax is deliberately small: $.a.b, $.a[0].b,
$["key with spaces"]. No filters, no wildcards, no recursive descent.
Requirements
- Answer fast. The timeout is configurable between 1 and 30 seconds, but the request happens while the phone is ringing — anything above about 2 seconds is too late to be useful.
- Say "not found" explicitly if you can, via a boolean field we can point
foundat. Otherwise an empty card is treated as not found. - Public address.
http://is tolerated for on-premise systems, but the host must not resolve into a private network. - Expect repeats. Results are cached per number, so a regular caller does not hit your API on every single call.
Send us the endpoint, the auth header, a sample response, and which field goes in which slot. We configure it and test it against your live endpoint from the panel.
Errors
Authentication and authorisation — 401, 403:
{ "error": "invalid_token", "error_description": "api key invalid or revoked" }
{ "error": "insufficient_scope", "error_description": "required scope: recordings.read" }
Validation and state — 409, 422:
{ "success": false, "message": "url must be an absolute https:// URL" }
| Status | Meaning | What to do |
|---|---|---|
200 | OK | — |
401 | No header, malformed key, or key revoked | Check the header; ask us to reissue |
403 | Key valid, scope missing | Ask us to widen the key's scopes |
404 | Unknown recording, or it belongs to another tenant, or it is past retention | Do not retry blindly |
409 | Action needs a subscription that does not exist | Subscribe first |
422 | Bad URL, unknown event type | Read message and fix the request |
5xx | Our side | Retry with backoff; tell us if it persists |
Some validation messages are in Russian — the private-address refusal, for instance. The status code and the field are what matter; the text is for a human reading a log.
Limits and retention
| Subject | Value |
|---|---|
| Recording retention | 90 days, then deleted |
| Recording format | MP3 today (8 kHz, mono/stereo, ~32 kbit/s); older calls WAV |
| Webhook timeout | 10 s per attempt |
| Webhook attempts | 10, spread over roughly 5 days |
| Signature replay window | 5 minutes |
| Key lifetime | Unlimited until revoked |
| Subscriptions per key | 1 |
| Request rate | No hard limit; poll /account no more than once a minute |
| Timezone | Asia/Tashkent, +05:00 all year — no daylight saving |
There is no polling endpoint for call history on this channel, so webhooks are the only source of call data. If your receiver is down for longer than the retry window, those calls are not recoverable from here — tell us and we can resend from our delivery log.
Go-live checklist
GET /api/v1/accountwith the sandbox key returns200.- Your endpoint is subscribed and
POST /api/v1/webhooks/testreports"delivered": true. - Your signature check rejects a tampered body — test it by changing one byte.
- Your handler deduplicates on
event_idand ignoresis_branch: true. - You answer
2xxin well under 10 seconds, doing the real work asynchronously. - Recording download works, and you store what you need — remember the 90-day window.
- If you use caller lookup: your endpoint answers in under 2 seconds and reports "not found" explicitly.
- Ask us for the production key and swap the string. Nothing else changes.