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
- 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.
- Map users. The CRM matches its users to Ringura agents with
GET /api/v1/agentsand keeps each agent'sid. - Subscribe. The CRM registers its webhook endpoint with
POST /api/v1/webhook-endpointsand stores the signing secret. - Click-to-call. A click on a number in the CRM sends
POST /api/v1/callswith the agent's id. - Screen-pop. When an agent takes a call or a chat, the
routing.assignedevent names the agent and the conversation; the CRM reads the contact and opens the matching record. - 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.
- Outcomes. Deposits and conversions the CRM knows about go back with
POST /api/v1/outcomesorPOST /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.
| What the CRM does | Scopes |
|---|---|
| Click-to-call | calls:write; users:read to find agent ids; queues:read to call through a queue |
| Screen-pop and contact lookup | contacts:read, conversations:read |
| Create contacts from CRM records | contacts:write |
| Subscribe to events and manage deliveries | webhooks:read, webhooks:write |
| Call and chat history | interactions:read, conversations:read, messages:read |
| Resolve a conversation with a disposition | conversations:write |
| Deposits and conversions | outcomes:write |
| Follow-up tasks for agents | tasks:write, tasks:read |
| Leads for a speed-to-lead campaign | leads:write |
| Bulk history exports | analytics: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
}
}
| Reason | What it means |
|---|---|
after_hours, holiday | Outside the calling hours where the callee is, or a public holiday there. Offer the user a later time. |
tenant_dnc, suppressed, national_register | On the account's do-not-call list, opted out, or on a national register (marketing calls). |
no_consent, consent_withdrawn | A marketing call without the contact's recorded consent, or after it was withdrawn. |
too_soon | The number was called in the last 10 minutes. |
frequency_day, frequency_week, frequency_gap | The number has had as many attempts as the account allows in the period, or the last one was too recent. |
abandoned_lock | An abandoned call locked the number for a while. |
emergency_number, invalid_number, no_calling_rules | The number cannot be called: an emergency number, outside its country's number plan, or a country with no calling hours set. |
caller_id_unverified | The caller ID chosen with callerId is not verified. |
telephony_balance_exhausted and other telephony_ reasons | Calling 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).
| Operation | Scope | Meaning |
|---|---|---|
POST /api/v1/calls | calls:write | Click-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/agents | users:read | Agents with their presence now. |
GET /api/v1/users | users:read | The tenant's console users and their roles. |
GET /api/v1/queues | queues:read | Queues 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.
| Operation | Scope | Meaning |
|---|---|---|
GET /api/v1/contacts | contacts:read | List contacts, oldest first; filter by phone (E.164) or e-mail for screen-pop. |
GET /api/v1/contacts/{id} | contacts:read | One contact with its identifiers. |
POST /api/v1/contacts | contacts:write | Create a contact with at least one phone number or e-mail address. |
PATCH /api/v1/contacts/{id} | contacts:write | Change a contact's name, language or time zone. |
GET /api/v1/conversations/{id} | conversations:read | One conversation. |
GET /api/v1/conversations | conversations:read | List conversations, oldest first. |
| Event type | Meaning | Data fields |
|---|---|---|
routing.assigned | An agent took a piece of work. | workItemId, conversationId, agentId |
routing.enqueued | Work entered a queue. | workItemId, conversationId, interactionId, channel, queueId |
conversation.transferred | A conversation moved to another queue or agent. | conversationId, queueId (optional), agentId (optional) |
contact.merged | Two contacts were found to be one customer and merged (automatically on verified identifiers, or by a person). | contactId, mergedContactId, mergeId, kind |
contact.unmerged | A 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:
| Event type | Meaning | Data fields |
|---|---|---|
conversation.updated | A conversation changed status (opened, resolved, closed) or was reopened by the customer. | conversationId, status, reason (optional), disposition (optional) |
dialler.attempt | An outbound call attempt changed state. | campaignId, attemptId, leadId, status, outcome (optional) |
dialler.disposition | An agent recorded a call outcome. | campaignId, attemptId, leadId, agentId, code |
message.created | A 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.recorded | A deposit or conversion was recorded and attributed. | outcomeId, kind, amountMinor, currency, occurredAt, agentId (optional), campaignId (optional), channel (optional), templateName (optional) |
contact.opted_in | A contact gave consent on a channel for a purpose. | contactId, channel, purpose |
contact.opted_out | A contact withdrew consent (STOP, an opt-out disposition, an unsubscribe). | contactId (optional), channel, purpose (optional) |
ticket.created | A conversation became a ticket with a reference number. | conversationId, caseNumber, priority |
ticket.updated | A ticket's case fields, status, priority, tags or assignee changed. | conversationId, caseNumber, changes |
survey.answered | A 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.
| Operation | Scope | Meaning |
|---|---|---|
POST /api/v1/webhook-endpoints | webhooks:write | Subscribe a URL to event types. The signing secret is in this answer only. |
GET /api/v1/event-types | webhooks:read | The event catalogue an endpoint can subscribe to. |
GET /api/v1/interactions | interactions:read | Calls, chats and threads, oldest first. |
GET /api/v1/interactions/{id} | interactions:read | One call, chat or thread. |
PATCH /api/v1/conversations/{id} | conversations:write | Change a conversation's subject, priority or tags, or resolve it (which sends the survey and writes back to CRMs). |
POST /api/v1/outcomes | outcomes:write | Record 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/deposits | outcomes:write | Record 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/tasks | tasks:write | Create 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:read | One 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'sid), not byCC-Delivery-Id: a replay is a new delivery of the same event. - A delivery that is retried can arrive after a later event. Compare
createdAtbefore overwriting a newer state in the CRM. - List the dead letter with
GET /api/v1/webhook-deliveries?status=deadand 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 withIdempotent-Replayed: true; the same key with another body is answered 422. Keys are kept for 24 hours.
| Operation | Scope | Meaning |
|---|---|---|
GET /api/v1/webhook-endpoints | webhooks:read | The tenant's webhook endpoints. |
PATCH /api/v1/webhook-endpoints/{id} | webhooks:write | Change an endpoint's URL, event types or status. |
POST /api/v1/webhook-endpoints/{id}/rotate-secret | webhooks:write | A new signing secret; for 24 hours deliveries are signed under both. |
GET /api/v1/webhook-deliveries | webhooks:read | Deliveries, newest first; dead ones are the dead letter. |
POST /api/v1/webhook-deliveries/{id}/replay | webhooks:write | Send 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.
| Operation | Scope | Meaning |
|---|---|---|
GET /api/v1/conversations/{id}/messages | messages:read | A conversation's messages across its threads, oldest first. |
POST /api/v1/exports | analytics:read | Queue 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:read | One export: its status, size, row count and the watermark to start the next pull from. |
GET /api/v1/exports/{id}/download | analytics:read | The 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. |
| Event type | Meaning | Data fields |
|---|---|---|
export.ready | An 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:
| Status | `error` | What to do |
|---|---|---|
| 400 | invalid_request, bad_cursor, and codes of the operation such as bad_number | Fix the request; sending it again unchanged gets the same answer. |
| 401 | unauthenticated | The token is missing, unknown, expired or revoked: ask the customer's administrator to connect again. |
| 403 | insufficient_scope, ip_not_allowed, feature_off | The 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. |
| 404 | not_found | No such object in this account. Ids belong to one account. |
| 405 | method_not_allowed | The path exists with another method. |
| 409 | a refusal reason, or a code for the object's state | Not allowed in the object's current state. Show message; for click-to-call, see Refusals. |
| 422 | idempotency_mismatch | The Idempotency-Key was used for a different request: use a new key. |
| 429 | rate_limited | Wait Retry-After seconds. |
| 5xx | Retry 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/callsis 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, andagentId,queueId,conversationIdandwebhookUrlfor the CRM folder;baseUrlishttps://api.ringura.com. Writes send a newIdempotency-Keyeach 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.mjsfor 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.