All developer pages

Developers

CRM integration guide

What a CRM vendor needs to put Ringura calls inside a CRM: click-to-call, screen-pop, and calls and outcomes logged back to the record. Every endpoint, field, scope and event on this page is in the OpenAPI document and the event catalogue.

How the pieces fit

  1. Connect. The customer's administrator creates an API token in the Ringura console with the scopes below and gives it to the CRM. A token belongs to one Ringura account and sees only that account's data.
  2. Map users. The CRM matches its users to Ringura agents with GET /api/v1/agents and keeps each agent's id.
  3. Subscribe. The CRM registers its webhook endpoint with POST /api/v1/webhook-endpoints and stores the signing secret.
  4. Click-to-call. A click on a number in the CRM sends POST /api/v1/calls with the agent's id.
  5. Screen-pop. When an agent takes a call or a chat, the routing.assigned event names the agent and the conversation; the CRM reads the contact and opens the matching record.
  6. Log back. Events and the interaction records give the CRM what to write on its activity: channel, direction, times, the agent's disposition and the outcome.
  7. Outcomes. Deposits and conversions the CRM knows about go back with POST /api/v1/outcomes or POST /api/v1/deposits, and Ringura attributes them to the agent, campaign and channel that last touched the contact.

Base URL: https://api.ringura.com/api/v1/. Accounts on a reseller's own console domain use that domain instead. Times are ISO 8601 in UTC and phone numbers E.164. The API overview covers pagination, idempotency and the conventions every operation shares.

API tokens and scopes

An administrator whose console session has passed MFA creates tokens in the console's Developer page, choosing the scopes and the limit of requests a minute. Send the token as Authorization: Bearer cck_... on every request; a token in a URL is never accepted. The account's API allow-list of IP addresses applies, so tell your customers which addresses your CRM calls from.

Ask for the scopes your integration uses and no more. A token without the scope an operation needs is answered 403 insufficient_scope, with the scope it needs in required.

API tokens and scopes: What the CRM does, Scopes
What the CRM doesScopes
Click-to-callcalls:write; users:read to find agent ids; queues:read to call through a queue
Screen-pop and contact lookupcontacts:read, conversations:read
Create contacts from CRM recordscontacts:write
Subscribe to events and manage deliverieswebhooks:read, webhooks:write
Call and chat historyinteractions:read, conversations:read, messages:read
Resolve a conversation with a dispositionconversations:write
Deposits and conversionsoutcomes:write
Follow-up tasks for agentstasks:write, tasks:read
Leads for a speed-to-lead campaignleads:write
Bulk history exportsanalytics:read

Building one integration for many Ringura accounts? Talk to sales before you start.

Click-to-call

POST /api/v1/calls rings the agent's phone first and then the number, presented from one of the account's verified numbers. Name the agent with agentId, or a queue with queueId to give the call to the queue's first free voice agent; one of the two is required. A call for a queue with no free agent waits up to 15 minutes for one.

number is E.164, or a national number with defaultCountry (two capital letters, such as GB). contactId ties the call to a Ringura contact, callerId chooses one of the account's verified numbers, and purpose is service (the default) or marketing, which needs the contact's recorded consent. Send an Idempotency-Key made from the CRM's own id for the click, so a retry after a timeout never places a second call.

curl -X POST 'https://api.ringura.com/api/v1/calls' \
  -H "Authorization: Bearer $CC_TOKEN" \
  -H 'Idempotency-Key: crm-call-7f3c9a21-0001' \
  -H 'Content-Type: application/json' \
  -d '{
  "number": "+441632960000",
  "agentId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d8e",
  "contactId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d8f",
  "purpose": "service"
}'

Every request passes the checks any outbound call passes: calling hours where the callee is, the do-not-call list and suppressions, consent for marketing, and one call to the same number in 10 minutes. A call that goes ahead is answered 201 with the request. status is dialling (the agent's phone is ringing) or waiting_agent (no free agent in the queue yet). Keep id and callId on the CRM activity.

{
  "request": {
    "id": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d90",
    "source": "api",
    "e164": "+441632960000",
    "purpose": "service",
    "status": "dialling",
    "refusal": null,
    "agentId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d8e",
    "queueId": null,
    "contactId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d8f",
    "callId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d91",
    "createdAt": "2027-01-04T09:00:00.000Z",
    "dialledAt": "2027-01-04T09:00:00.000Z"
  }
}

Refusals

A call the checks refuse is answered 409. error holds the reason, message says it in words, and request is the refused request as recorded:

{
  "error": "after_hours",
  "message": "the call was refused: after_hours",
  "request": {
    "id": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d92",
    "source": "api",
    "e164": "+441632960000",
    "purpose": "service",
    "status": "refused",
    "refusal": "after_hours",
    "agentId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d8e",
    "queueId": null,
    "contactId": null,
    "callId": "0192f5a0-7c1e-7d3a-9b2e-3f4a5b6c7d93",
    "createdAt": "2027-01-04T21:30:00.000Z",
    "dialledAt": null
  }
}
Click-to-call: Reason, What it means
ReasonWhat it means
after_hours, holidayOutside the calling hours where the callee is, or a public holiday there. Offer the user a later time.
tenant_dnc, suppressed, national_registerOn the account's do-not-call list, opted out, or on a national register (marketing calls).
no_consent, consent_withdrawnA marketing call without the contact's recorded consent, or after it was withdrawn.
too_soonThe number was called in the last 10 minutes.
frequency_day, frequency_week, frequency_gapThe number has had as many attempts as the account allows in the period, or the last one was too recent.
abandoned_lockAn abandoned call locked the number for a while.
emergency_number, invalid_number, no_calling_rulesThe number cannot be called: an emergency number, outside its country's number plan, or a country with no calling hours set.
caller_id_unverifiedThe caller ID chosen with callerId is not verified.
telephony_balance_exhausted and other telephony_ reasonsCalling is not available for the account at the moment, for example because the prepaid balance has run out.

Treat any other error on a 409 the same way: show message to the user and do not retry by yourself. A 400 is a mistake in the request: bad_number (not a phone number), unknown_agent, unknown_queue, unknown_caller_id (the account has no verified number to call from) or bad_request (neither an agent nor a queue named).

Click-to-call: Operations 11
OperationScopeMeaning
POST /api/v1/callscalls:writeClick-to-call from a CRM: the named agent's phone rings first, then the number, presented from one of your verified numbers, through the outbound gate. A refusal (calling hours, DNC, consent, a number called in the last 10 minutes, telephony not active) answers 409 with the reason.
GET /api/v1/agentsusers:readAgents with their presence now.
GET /api/v1/usersusers:readThe tenant's console users and their roles.
GET /api/v1/queuesqueues:readQueues and the channels they take.

Screen-pop and contact lookup

To open the CRM record when an agent takes a call or a chat, subscribe to routing.assigned. Its data names the agentId and the conversationId. Read the conversation for its contactId, then the contact for its phone numbers and e-mail addresses (identifiers), and open the matching record for the CRM user mapped to that agent.

To go the other way, from a CRM record to Ringura, look the contact up by phone or e-mail: GET /api/v1/contacts?phone=%2B441632960000. The phone number is E.164, and its + is written %2B in a query string; a bare + reads as a space and the request is answered 400.

Contacts carry no field for the CRM's own id, so keep the Ringura contact id on the CRM record. A CRM record with no Ringura contact yet can be created with POST /api/v1/contacts and at least one e164 or email identifier. Subscribe to contact.merged to update the id you keep when two contacts become one.

Screen-pop and contact lookup: Operations 4
OperationScopeMeaning
GET /api/v1/contactscontacts:readList contacts, oldest first; filter by phone (E.164) or e-mail for screen-pop.
GET /api/v1/contacts/{id}contacts:readOne contact with its identifiers.
POST /api/v1/contactscontacts:writeCreate a contact with at least one phone number or e-mail address.
PATCH /api/v1/contacts/{id}contacts:writeChange a contact's name, language or time zone.
GET /api/v1/conversations/{id}conversations:readOne conversation.
GET /api/v1/conversationsconversations:readList conversations, oldest first.
Screen-pop and contact lookup: Event type 5
Event typeMeaningData fields
routing.assignedAn agent took a piece of work.workItemId, conversationId, agentId
routing.enqueuedWork entered a queue.workItemId, conversationId, interactionId, channel, queueId
conversation.transferredA conversation moved to another queue or agent.conversationId, queueId (optional), agentId (optional)
contact.mergedTwo contacts were found to be one customer and merged (automatically on verified identifiers, or by a person).contactId, mergedContactId, mergeId, kind
contact.unmergedA contact merge was undone: what it moved is back on the merged contact.contactId, mergedContactId, mergeId

Logging calls and outcomes back

Register the CRM's endpoint with POST /api/v1/webhook-endpoints: its url (https, on a public address), the eventTypes it wants, and an optional description. The answer carries the signing secret once; store it encrypted. GET /api/v1/event-types lists every type you can subscribe to.

Each delivery is a JSON POST with the headers CC-Event-Id, CC-Event-Type, CC-Delivery-Id and CC-Signature, and this body:

{
  "id": "evt_...",
  "type": "dialler.disposition",
  "tenantId": "...",
  "createdAt": "2027-01-04T09:04:12.000Z",
  "data": {
    "campaignId": "...",
    "attemptId": "...",
    "leadId": "...",
    "agentId": "...",
    "code": "sale"
  }
}

The events a CRM usually logs:

Logging calls and outcomes back: Event type 5
Event typeMeaningData fields
conversation.updatedA conversation changed status (opened, resolved, closed) or was reopened by the customer.conversationId, status, reason (optional), disposition (optional)
dialler.attemptAn outbound call attempt changed state.campaignId, attemptId, leadId, status, outcome (optional)
dialler.dispositionAn agent recorded a call outcome.campaignId, attemptId, leadId, agentId, code
message.createdA message was received or sent on any channel. Internal notes (and replies synced from Slack or Teams) never leave: only inbound and outbound messages are sent.conversationId, interactionId, messageId, channel, direction, kind
outcome.recordedA deposit or conversion was recorded and attributed.outcomeId, kind, amountMinor, currency, occurredAt, agentId (optional), campaignId (optional), channel (optional), templateName (optional)
contact.opted_inA contact gave consent on a channel for a purpose.contactId, channel, purpose
contact.opted_outA contact withdrew consent (STOP, an opt-out disposition, an unsubscribe).contactId (optional), channel, purpose (optional)
ticket.createdA conversation became a ticket with a reference number.conversationId, caseNumber, priority
ticket.updatedA ticket's case fields, status, priority, tags or assignee changed.conversationId, caseNumber, changes
survey.answeredA customer answered a survey.surveyId, conversationId, kind, score, via, followUpConversationId (optional)

For the call itself, list the conversation's interactions with GET /api/v1/interactions?conversationId=...: each one has its channel, direction, status, startedAt, endedAt and the remote and local addresses. When the agent finishes the work in the CRM rather than in Ringura, update the conversation with status resolved and a disposition.

Outcomes the CRM records, such as a sale or a deposit, go back with POST /api/v1/outcomes (up to 500 at a time; externalRef makes each one count once) or POST /api/v1/deposits for a back office's deposits. A follow-up an agent must do is a task: POST /api/v1/tasks routes it to a queue like a conversation.

Logging calls and outcomes back: Operations 8
OperationScopeMeaning
POST /api/v1/webhook-endpointswebhooks:writeSubscribe a URL to event types. The signing secret is in this answer only.
GET /api/v1/event-typeswebhooks:readThe event catalogue an endpoint can subscribe to.
GET /api/v1/interactionsinteractions:readCalls, chats and threads, oldest first.
GET /api/v1/interactions/{id}interactions:readOne call, chat or thread.
PATCH /api/v1/conversations/{id}conversations:writeChange a conversation's subject, priority or tags, or resolve it (which sends the survey and writes back to CRMs).
POST /api/v1/outcomesoutcomes:writeRecord deposits and conversions (up to 500). Each is attributed to the agent, campaign, channel and WhatsApp template that last touched the contact inside the attribution window. externalRef makes each idempotent: a repeat is counted once. Items that do not fit are reported in problems and skipped.
POST /api/v1/depositsoutcomes:writeRecord client deposits from your back office or CRM: one deposit as the body (a webhook) or { deposits: [...] } (up to 500). Each is matched to the contact (contactId, phone, email, or leadExternalId: the id your CRM gave the lead) and attributed to the agents, conversations and campaign that touched the contact inside the deposit window. reference makes each idempotent: a repeat is counted once. firstDeposit marks a first-time deposit; left out, the first deposit on record for the contact is the first. Amounts and references only: no card or bank details are accepted.
POST /api/v1/taskstasks:writeCreate a task (a callback, voicemail, web form, CRM follow-up or back-office case) routed to the agents of a queue like a conversation; externalRef makes it idempotent.
GET /api/v1/tasks/{id}tasks:readOne task with its routing state.

Verifying webhook signatures

CC-Signature is t=<unix seconds>,v1=<hex>, with a second v1 while a secret rotates. The v1 value is the HMAC-SHA-256, under the endpoint's secret, of the timestamp, a full stop and the raw request body. Check it against the raw bytes before parsing the JSON, reject timestamps more than 5 minutes away, and remember each delivery you accepted by its timestamp and body until its timestamp is too old, so a replayed request is refused.

Node.js

// Verify a webhook delivery (Node.js 18 or later, no dependencies).
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;
// Deliveries already accepted (a digest of the timestamp and the body), each kept until its
// timestamp is too old to be accepted anyway. With several processes, keep them in your database.
const seen = new Map();

/**
 * header:  the CC-Signature request header
 * rawBody: the request body exactly as received (a Buffer or a string)
 * secrets: your endpoint secret, or [new, old] while a rotation is under way
 * Returns true only for a genuine delivery that has not been seen before.
 */
export function verifyWebhook(header, rawBody, secrets, nowMs = Date.now()) {
  let t = null;
  const v1 = [];
  for (const part of String(header ?? '').split(',')) {
    const [k, v = ''] = part.split('=').map((s) => s.trim());
    if (k === 't' && /^[0-9]{1,12}$/.test(v)) t = Number(v);
    else if (k === 'v1' && /^[0-9a-f]{64}$/.test(v)) v1.push(v);
  }
  if (t === null || v1.length === 0) return false;
  const now = Math.floor(nowMs / 1000);
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
  const genuine = [secrets].flat().some((secret) => {
    const want = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
    return v1.some((got) => timingSafeEqual(want, Buffer.from(got, 'hex')));
  });
  if (!genuine) return false;
  // A replay is the same timestamp and body, whichever of its signatures it still carries:
  // during a rotation a delivery has two, and resending it with only one is still a replay.
  const key = createHash('sha256').update(`${t}.`).update(body).digest('hex');
  for (const [k, until] of seen) if (until < now) seen.delete(k);
  if (seen.has(key)) return false;
  seen.set(key, t + TOLERANCE_SECONDS);
  return true;
}

// With node:http:
//   const chunks = [];
//   req.on('data', (c) => chunks.push(c)).on('end', () => {
//     const raw = Buffer.concat(chunks);
//     if (!verifyWebhook(req.headers['cc-signature'], raw, process.env.CC_WEBHOOK_SECRET)) return res.writeHead(401).end();
//     res.writeHead(204).end();
//     handle(JSON.parse(raw)); // a retry is a new delivery: use the CC-Event-Id header to ignore an event you already handled
//   });

Python

# Verify a webhook delivery (Python 3.8 or later, standard library only).
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300
# Deliveries already accepted (a digest of the timestamp and the body), each kept until its
# timestamp is too old to be accepted anyway. With several processes, keep them in your database.
_seen = {}


def verify_webhook(header, raw_body, secrets, now=None):
    """header:   the CC-Signature request header
    raw_body: the request body exactly as received (bytes)
    secrets:  your endpoint secret, or [new, old] while a rotation is under way
    Returns True only for a genuine delivery that has not been seen before."""
    t = None
    v1 = []
    for part in str(header or "").split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if key == "t" and re.fullmatch(r"[0-9]{1,12}", value):
            t = int(value)
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            v1.append(value)
    if t is None or not v1:
        return False
    now = int(time.time() if now is None else now)
    if abs(now - t) > TOLERANCE_SECONDS:
        return False
    body = raw_body if isinstance(raw_body, bytes) else str(raw_body).encode("utf-8")
    signed = str(t).encode("ascii") + b"." + body
    genuine = False
    for secret in [secrets] if isinstance(secrets, str) else secrets:
        want = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(want, got) for got in v1):
            genuine = True
    if not genuine:
        return False
    # A replay is the same timestamp and body, whichever of its signatures it still carries:
    # during a rotation a delivery has two, and resending it with only one is still a replay.
    key = hashlib.sha256(signed).hexdigest()
    for k, until in list(_seen.items()):
        if until < now:
            del _seen[k]
    if key in _seen:
        return False
    _seen[key] = t + TOLERANCE_SECONDS
    return True

# In a web framework, pass the raw body bytes, never the parsed JSON (Flask: request.get_data()).
# Answer 401 when verify_webhook() is False, else 2xx, and use the CC-Event-Id header to ignore
# an event you already handled: a retry or a replay is the same event again.

Retries, duplicates and idempotency

  • Answer with a 2xx status once the event is safely stored. Anything else is retried: 8 attempts in all, the wait doubling from 30 seconds (at most 6 hours, plus up to 20% jitter). After the last attempt the delivery is dead.
  • Ignore an event you have already handled by its event id (CC-Event-Id, the body's id), not by CC-Delivery-Id: a replay is a new delivery of the same event.
  • A delivery that is retried can arrive after a later event. Compare createdAt before overwriting a newer state in the CRM.
  • List the dead letter with GET /api/v1/webhook-deliveries?status=dead and replay a delivery once your endpoint is fixed.
  • Rotating the secret signs with both the new and the old secret for 24 hours, so the CRM can switch without missing a delivery.
  • Every write you send can carry an Idempotency-Key (8 to 100 letters, digits or _ . : -). A retry with the same key and body gets the first answer with Idempotent-Replayed: true; the same key with another body is answered 422. Keys are kept for 24 hours.
Retries, duplicates and idempotency: Operations 2
OperationScopeMeaning
GET /api/v1/webhook-endpointswebhooks:readThe tenant's webhook endpoints.
PATCH /api/v1/webhook-endpoints/{id}webhooks:writeChange an endpoint's URL, event types or status.
POST /api/v1/webhook-endpoints/{id}/rotate-secretwebhooks:writeA new signing secret; for 24 hours deliveries are signed under both.
GET /api/v1/webhook-deliverieswebhooks:readDeliveries, newest first; dead ones are the dead letter.
POST /api/v1/webhook-deliveries/{id}/replaywebhooks:writeSend a delivered or dead event again, as a new delivery with the same event id.

Recordings and transcripts

API v1 has no operation for call recordings or transcripts, and an interaction carries no link to either. If your integration needs them, tell us.

What the API does give for history: the messages of a conversation on text channels, and bulk exports of the conversations, work_items, dial_attempts, outcomes and insights datasets as CSV, JSON lines or Parquet. An export is built in the background; the export.ready event, or reading the export, says when to download it.

Recordings and transcripts: Operations 3
OperationScopeMeaning
GET /api/v1/conversations/{id}/messagesmessages:readA conversation's messages across its threads, oldest first.
POST /api/v1/exportsanalytics:readQueue an export of a dataset (conversations, work_items, dial_attempts, outcomes, insights) as CSV, JSON lines or Parquet. Start from a time (since), from the watermark of a previous export (cursor), or from the beginning. It is built asynchronously; an export.ready webhook (or polling GET /exports/{id}) says when to download.
GET /api/v1/exports/{id}analytics:readOne export: its status, size, row count and the watermark to start the next pull from.
GET /api/v1/exports/{id}/downloadanalytics:readThe finished export as text/csv, application/x-ndjson or application/vnd.apache.parquet. X-Export-Watermark carries the watermark for the next pull and X-Export-Has-More says to pull again at once.
Recordings and transcripts: Event type 4
Event typeMeaningData fields
export.readyAn analytics export finished; the data is at downloadUrl until it expires.exportId, dataset, format, rows, hasMore, nextCursor, downloadUrl (optional), downloadExpiresAt (optional)

Rate limits

Each token has its own limit of requests a minute, set when it is created. Every answer carries RateLimit-Limit and RateLimit-Remaining; over the limit, the answer is 429 rate_limited with Retry-After in seconds. Wait that long before the next request.

A CRM with many users shares one token per Ringura account, so send its requests through one queue that respects these headers rather than from each user's session. Lists return at most 200 rows a page (limit) and continue with cursor; for a large backfill, use an export instead of paging.

Error codes

Errors are JSON: { "error": "code", "message": "..." }, with problems listing each field that did not fit on a 400. The codes a CRM meets:

Error codes: Status, `error`, What to do
Status`error`What to do
400invalid_request, bad_cursor, and codes of the operation such as bad_numberFix the request; sending it again unchanged gets the same answer.
401unauthenticatedThe token is missing, unknown, expired or revoked: ask the customer's administrator to connect again.
403insufficient_scope, ip_not_allowed, feature_offThe token lacks the scope named in required, your address is not on the account's API allow-list, or API access is switched off for the account.
404not_foundNo such object in this account. Ids belong to one account.
405method_not_allowedThe path exists with another method.
409a refusal reason, or a code for the object's stateNot allowed in the object's current state. Show message; for click-to-call, see Refusals.
422idempotency_mismatchThe Idempotency-Key was used for a different request: use a new key.
429rate_limitedWait Retry-After seconds.
5xxRetry reads, and writes sent with an Idempotency-Key, with back-off.

Testing your integration

  • API v1 has no sandbox mode: every call placed with POST /api/v1/calls is a real call. Test with a Ringura account kept for testing, agents whose phones you hold and destination numbers you own.
  • In unit tests and mocks, use numbers from the range set aside for drama, such as +441632960000, and the example ids from the API reference.
  • Webhook endpoints must use https and resolve to public addresses, and redirects are not followed, so expose a local receiver through an https tunnel while you develop.
  • Exercise your receiver without placing calls: replay a delivered delivery with the replay operation, and check that the second copy is ignored.
  • Test a refusal: a second click-to-call to the same number within 10 minutes is refused with too_soon.
  • Rotate the secret while your receiver runs and check that deliveries signed with both secrets are accepted once.

Downloads

  • Postman collection (format v2.1): a CRM integration folder with the calls of this guide in order, then every API v1 operation by group. Set the collection variable token, and agentId, queueId, conversationId and webhookUrl for the CRM folder; baseUrl is https://api.ringura.com. Writes send a new Idempotency-Key each time.
  • Node.js example (Node.js 18 or later, no dependencies): agent list, contact lookup, click-to-call with refusals, webhook subscription, and a receiver that verifies signatures and ignores duplicates. Run node crm-example.mjs for its commands.
  • Python signature check: the code above, as a file.
  • OpenAPI document (OpenAPI 3.1): generate a client in your own language.

Building a CRM integration?

Tell us what your CRM needs and which accounts you are connecting.