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.

Base URL 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:

CapabilityDirectionHow
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.
Not available over an API key Placing outbound calls (click-to-call), reading historical call logs, changing operator state and sending SMS exist in Ringline but are not part of this key-based API. If you need any of them, ask us — they are a separate, authenticated channel.

Quick start

  1. Get a key. We issue it and send it to you out of band. It looks like rl_live_…, roughly 51 characters.
  2. Check it works.
    curl -H "Authorization: Bearer rl_live_YOUR_KEY" \
         https://app.ringline.uz/api/v1/account
    A 200 with a domain_name means you are connected.
  3. 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":["*"]}'
  4. Prove delivery without waiting for a real call.
    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"}'
    You get back the exact HTTP status and body your server replied with, so you can see your own signature check pass or fail.
  5. Handle events. Verify the signature, then act on call.*. Reply 2xx within 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.

ScopeNeeded for
calls.readReceiving call events
recordings.readGET /api/v1/recordings/{call_uuid}
operators.readGET /api/v1/extensions
webhooks.manageSubscribing, 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.

Same key format everywhere The production key differs from the sandbox key only in the tenant it points at. Switching to production means swapping one string — no code changes.

Endpoints

GET/api/v1/account any scope

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.

GET/api/v1/extensions operators.read

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
    }
  ]
}
FieldMeaning
presence_modefull takes calls; outgoing_only and offline are never rung.
inbound_enabledAdministrative switch. False means this operator is excluded from incoming calls.
enabledWhether 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".

GET/api/v1/webhooks webhooks.manage
{ "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.

POST/api/v1/webhooks webhooks.manage

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:

Omitting events is the same as ["*"]. An unknown event type is a 422 rather than a silent no-op.

DELETE/api/v1/webhooks webhooks.manage

Stops delivery. The key stays valid and the secret is unchanged, so you can resubscribe later without re-keying anything.

POST/api/v1/webhooks/test webhooks.manage

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" }
}
FieldMeaning
deliveredTrue when your server answered 2xx.
http_statusYour status code. 0 means the connection itself failed — DNS, TLS or timeout.
response_bodyFirst 2000 characters of your reply, which is usually enough to see your own error message.
Test events are marked The payload has "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.
GET/api/v1/recordings/{call_uuid} recordings.read

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
Recordings are deleted after 90 days Storage is a rolling 90-day window. After that the call stays in the history but the audio is gone and this endpoint answers 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.

EventWhenTypical 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.
Only these four There is no separate event for a recording becoming available — the recording arrives inside 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"
  }
}
FieldNotes
event_idUnique and stable per (call, event type). Use it as an idempotency key — a retry repeats the same event_id.
occurred_atISO 8601 with the tenant's offset (Uzbekistan is +05:00, no daylight saving).
domain_uuidWhich tenant this call belongs to.
customer_numberMatch 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_numberRaw calling and dialled numbers as the PBX saw them.
answered_by_operatorTrue 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_secondsRing time before pickup — your queue-performance number.
talk_secondsConversation length. Zero when nobody answered.
total_secondsWhole call including ring time.
operatorWho handled it. Null on an unanswered group call — naming one of the operators who simply did not pick up would be misleading.
recording.availableFalse when there is no recording, and also for files under 0.5 s, which are silence artefacts rather than conversations.
hangup_causeFreeSWITCH/SIP cause: NORMAL_CLEARING, USER_BUSY, NO_ANSWER, ORIGINATOR_CANCEL, …
A declined call looks busy When an operator presses "decline" on the softphone, the PBX reports it exactly as a busy line (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.

Sign the raw bytes Compute the HMAC over the body exactly as received, before any JSON parsing or re-serialisation. Re-encoding changes whitespace and key order, and the signature will not match. Also reject a timestamp more than 5 minutes old, so a captured request cannot be replayed later.
<?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

PropertyValue
MethodPOST, Content-Type: application/json; charset=utf-8
Expected replyAny 2xx, within 10 seconds
First attemptImmediately, from the PBX event daemon
RetriesAfter 1 min, 5 min, 30 min, 2 h, then once every 24 h
Attempt limit10, after which the event is abandoned
4xx from youTreated as final: logged, not retried
5xx, timeout, TLS or DNS failureRetried on the schedule above
OrderingNot guaranteed. A retried ringing can arrive after completed.
RedirectsNot followed

Practical consequences for your receiver:

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:

PlaceholderExpands toExample
{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 separators998901234567
{domain_uuid}The tenant, useful if you serve several of our customers0e0d5944-…

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 slotShown asGood content
titleHeadlineCustomer name
subtitleSecond lineMembership level, segment
info_1Detail lineNext booking: date and area
info_2Detail lineOutstanding balance, note, visit count
urlClickable linkDeep 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

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

Two different error shapes The authentication layer and the endpoint handlers answer in different formats. Handle both.

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" }
StatusMeaningWhat to do
200OK—
401No header, malformed key, or key revokedCheck the header; ask us to reissue
403Key valid, scope missingAsk us to widen the key's scopes
404Unknown recording, or it belongs to another tenant, or it is past retentionDo not retry blindly
409Action needs a subscription that does not existSubscribe first
422Bad URL, unknown event typeRead message and fix the request
5xxOur sideRetry 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

SubjectValue
Recording retention90 days, then deleted
Recording formatMP3 today (8 kHz, mono/stereo, ~32 kbit/s); older calls WAV
Webhook timeout10 s per attempt
Webhook attempts10, spread over roughly 5 days
Signature replay window5 minutes
Key lifetimeUnlimited until revoked
Subscriptions per key1
Request rateNo hard limit; poll /account no more than once a minute
TimezoneAsia/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

  1. GET /api/v1/account with the sandbox key returns 200.
  2. Your endpoint is subscribed and POST /api/v1/webhooks/test reports "delivered": true.
  3. Your signature check rejects a tampered body — test it by changing one byte.
  4. Your handler deduplicates on event_id and ignores is_branch: true.
  5. You answer 2xx in well under 10 seconds, doing the real work asynchronously.
  6. Recording download works, and you store what you need — remember the 90-day window.
  7. If you use caller lookup: your endpoint answers in under 2 seconds and reports "not found" explicitly.
  8. Ask us for the production key and swap the string. Nothing else changes.