OmertaCall

OmertaCall API

Trigger outbound calls from your CRM, look up rates, check what a call cost, and get a webhook when it finishes — without ever touching the underlying phone system directly.

Already a OmertaCall customer? Log in to the portal and open Developer to generate your API key.

Open the portal →

Authentication

Every request needs a Bearer key in the Authorization header:

Authorization: Bearer mc_live_...

The portal's Developer page can hold two active keys at once: generating a new key keeps the previous one working until you revoke it, so you can rotate keys with zero downtime. A machine-readable contract for this whole API lives at GET /v1/openapi.json (OpenAPI 3.1).

Place a call

POST/v1/click-to-call

Rings device first; once answered, bridges to destination. Limited to one call per device every 3 seconds.

curl -X POST https://api.omertacall.com/v1/click-to-call \
  -H "Authorization: Bearer mc_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device": "101",
    "destination": "+15551234567",
    "client_reference": "lead-42"
  }'

# 202 Accepted
{"ok": true, "call_id": "17834168392648973"}

Request body

FieldRequiredDescription
deviceYesThe device's SIP username / extension exactly as shown on the portal's Lines page (e.g. 9995552426) — not its numeric id
destinationYesThe number to bridge to once device answers
client_referenceNoYour own id, echoed back in the webhook (max 128 chars)

Idempotency & rate limits

Send an Idempotency-Key header (any unique string per intended call, max 128 chars) and a retried request within 10 minutes returns the original call_id with "replayed": true instead of dialing twice. Reusing a key with a different device/destination returns 409.

Limits: one call per device every 3 seconds, and 60 requests per key per minute — both answer 429 with Retry-After. A failed initiation does not consume the device throttle, so an immediate retry is allowed.

Errors

StatusMeaning
401Missing or invalid API key
400device or destination missing
404device not found on your account
409Idempotency-Key reused with different parameters
422The device has no CallerID and no DID, so there is no number to ring it back on — set one in the portal or contact support
429Device used within the last 3 seconds, or the key exceeded 60 requests/minute — see Retry-After
502The call could not be initiated

Fetch a recording

GET/v1/calls/{call_id}/recording

Returns the call's audio as audio/mpeg once available, scoped to your own account. Returns 404 if there's no recording yet or the id doesn't belong to you.

Fetch a transcript

GET/v1/calls/{call_id}/transcript

Enable transcripts_read on each API key in the portal Developer page. Existing and newly generated keys start with this permission off. This no-charge endpoint returns a stored transcript as 200 or 202 {"status":"processing"} while transcription runs. Retry the same URL after Retry-After; retries are safe after a lost acknowledgement. Unknown, foreign, unresolved, or unrecorded calls return 404.

Responses are private and no-store. Limits: 60 requests per key per minute and 200 new transcripts per account per UTC day by default. A 429 includes Retry-After. Missing keys return 401, missing permission 403, a recording over four hours 413, a failed transcription 503, and recording preflight failures 502. A transient transcription failure is retried by a later poll after 30 seconds (you receive 202 with Retry-After meanwhile). Each call gets at most 3 attempts; after that, and for missing recordings, recordings over four hours, or oversize transcripts, the error is permanent and no new work starts on later polls.

Segments use seconds and speaker: "unknown" until speaker detection is available. Interrupted jobs can retry after a five-minute lease, keeping their original daily reservation and sharing the same 3-attempt limit. This may repeat transcription work at no charge to the client. Background transcription must finish within the platform’s 30-second post-response window. A recording whose transcription keeps exceeding it returns a stable 503 once the third attempt’s lease expires; durable long-running transcription is not available yet.

curl https://api.omertacall.com/v1/calls/CALL_ID/transcript \
  -H "Authorization: Bearer $API_KEY"
const response = await fetch('https://api.omertacall.com/v1/calls/CALL_ID/transcript', {
  headers: { Authorization: 'Bearer ' + apiKey }
});
const transcript = await response.json();
import requests
response = requests.get('https://api.omertacall.com/v1/calls/CALL_ID/transcript',
  headers={"Authorization": "Bearer " + api_key})
transcript = response.json()
{"language":"en","segments":[{"speaker":"unknown","start":0,"end":1.2,"text":"Hello"}],"text":"Hello"}

List & poll your calls

GET/v1/calls

Every click-to-call your account has placed, newest first — use it to reconcile against missed webhooks. Query params: since (ISO 8601 or epoch milliseconds), limit (1–200, default 50), offset.

curl "https://api.omertacall.com/v1/calls?since=2026-07-30T00:00:00Z" \
  -H "Authorization: Bearer mc_live_..."

# 200 OK
{
  "total": 2,
  "calls": [
    { "call_id": "17854030425788856", "destination": "+16473035551", "initiated_at": "2026-07-30T09:17:22.720Z" },
    { "call_id": "17854029866509020", "destination": "+14378898460", "initiated_at": "2026-07-30T09:16:26.804Z" }
  ]
}

GET/v1/calls/{call_id}

Poll one call's outcome without webhooks. status is pending until the call record lands, then the same vocabulary as the webhook, plus duration_seconds and cost.

curl https://api.omertacall.com/v1/calls/17854030425788856 \
  -H "Authorization: Bearer mc_live_..."

# 200 OK
{
  "call_id": "17854030425788856",
  "destination": "+16473035551",
  "initiated_at": "2026-07-30T09:17:22.720Z",
  "status": "answered",
  "duration_seconds": 34,
  "cost": 0.025
}

Check agent status

GET/v1/agents/status

Which of your own devices are online and which are currently on a call — for routing a click-to-call before dialing, or a live dashboard. Poll it; there's no per-request rate limit.

curl https://api.omertacall.com/v1/agents/status \
  -H "Authorization: Bearer mc_live_..."

# optionally filter to specific devices:
curl "https://api.omertacall.com/v1/agents/status?device=101,102" \
  -H "Authorization: Bearer mc_live_..."

# 200 OK
{
  "agents": [
    {
      "device": "101",
      "online": true,
      "on_call": true,
      "callback_ready": true,
      "destination": "+15551234567",
      "duration_seconds": 42,
      "last_call_end": "2026-07-07T09:34:41.000Z"
    },
    {
      "device": "102",
      "online": false,
      "on_call": false,
      "callback_ready": false,
      "destination": null,
      "duration_seconds": null,
      "last_call_end": null
    }
  ]
}

destination/duration_seconds are only non-null while on_call is true. last_call_end is when the device last finished a call, or null if it never has. callback_ready is false when the device has no CallerID and no DID — a click-to-call on it would return 422, so grey the button out instead.

Look up a rate

GET/v1/rates/lookup

The per-minute rate for a destination number on your account's rate plan, matched to the most specific dial prefix on file (e.g. a city code beats its country code when both exist).

curl "https://api.omertacall.com/v1/rates/lookup?destination=15551234567" \
  -H "Authorization: Bearer mc_live_..."

# 200 OK
{
  "destination": "15551234567",
  "destination_name": "United States / Canada",
  "prefix": "1",
  "rate_per_minute": 0.012,
  "connection_fee": 0
}

Errors

StatusMeaning
400destination query param missing
404No rate plan on your account, or no prefix matches this destination

Look up a call's cost

GET/v1/calls/{call_id}/cost

The actual billed cost of a call already placed through POST /v1/click-to-call — not an estimate. call_id is the value returned when you placed the call.

curl https://api.omertacall.com/v1/calls/17834168392648973/cost \
  -H "Authorization: Bearer mc_live_..."

# 200 OK — call has finished
{
  "call_id": "17834168392648973",
  "destination": "+15551234567",
  "duration_seconds": 184,
  "cost": 0.037,
  "status": "answered"
}

# 202 Accepted — call hasn't resolved yet, poll again shortly
{"call_id": "17834168392648973", "status": "pending"}

status is one of answered, no_answer, busy, failed, canceled — same vocabulary as the webhook below. Returns 404 if call_id is unknown or doesn't belong to your account.

Webhooks

When a call finishes, we POST a call.completed event to the webhook URL you configure in the portal:

POST <your webhook url>
Content-Type: application/json
X-OmertaCall-Signature: sha256=<hmac>
X-OmertaCall-Timestamp: <unix ms>

{
  "event": "call.completed",
  "event_id": "evt_17834168392648973",
  "call_id": "17834168392648973",
  "client_reference": "lead-42",
  "device": "101",
  "destination": "+15551234567",
  "status": "answered",
  "duration_seconds": 42,
  "initiated_at": "2026-07-07T09:33:59.000Z",
  "ended_at": "2026-07-07T09:34:41.000Z",
  "recording_available": true
}

status is one of answered, no_answer, busy, failed, canceled. We retry a failed delivery after 5s, 30s, then 120s. After 10 consecutive failed deliveries to your URL, delivery is suspended for the whole account — the portal's Developer page shows a warning with a one-click Re-enable delivery button once your endpoint is fixed. Saving a new webhook URL also resets the counter.

Verifying the signature

The webhook secret is shown in the portal's Developer page. Recompute the signature and compare:

const signed = timestamp + '.' + rawRequestBody
const expected = 'sha256=' + hmacSha256Hex(signed, webhookSecret)
// compare to the X-OmertaCall-Signature header (constant-time compare)

Number intelligence

Enable the explicit number_intelligence scope for each active API key on the portal Developer page. Existing and newly generated keys start with this scope off. OmertaCall must also enable the requested service.

Use E.164 numbers: + followed by 6–15 digits, first digit non-zero. A batch contains 1–100 distinct numbers. Invalid or duplicate numbers reject the whole request before dispatch.

Every POST requires an Idempotency-Key (1–128 printable ASCII characters). Reuse the exact key and ordered payload after a timeout to replay the stored result without a second charge. A changed number, order or service conflicts (409); an expired key returns idempotency_expired (409). Do not create a new key to retry an uncertain request.

POST limits per key: 10 requests/second and 100 numbers/second in independent fixed windows. A refused rate admission consumes neither quota. Admitted valid requests count even if a later operation fails.

Result responses include x-charged-amount (USD decimal with six places) and x-charged-currency: USD. The body uses charged: {amount_minor, currency, scale: 6}; divide amount_minor by 1,000,000. Replay uses the stored charge, even if current prices have changed. Every performed lookup costs the same flat price, whatever the sources answer (full, partial or no answer at all); a lookup that could not be performed is not charged.

Errors contain {"error":"CODE"}. 400 invalid_request; 401 unauthorized; 402 insufficient_funds; 403 forbidden or disabled; 404 not_found; 409 idempotency_conflict or idempotency_expired; 429 rate_limited or daily_cap; 503 unavailable. Provider failure returns a stored unavailable result with zero charge and HTTP 503. A pending request returns 202 in_progress with Retry-After: 1. For 429, Retry-After is seconds until the rate window or UTC daily cap resets. Validation, cap, balance and service refusals dispatch no check and charge zero.

Spam checks

Verdicts: likely_spam, suspicious or no_risk_signals, with source answers from Truecaller, Hiya, Whoscall, Tellows and Clever Dialer.

$0.500000 per lookup performed; no charge if the lookup could not be performed.

POST /v1/spam-checks accepts {"number":"+12025550142"}. POST /v1/spam-checks/batch accepts {"numbers":["+12025550142","+14155550101"]}.

GET /v1/spam-checks/{id} accepts a request ID or item ID and returns the containing request. Other accounts' IDs and kind mismatches return 404. GET /v1/spam-checks?limit=50&offset=0 lists this service's completed results from the last 90 days, newest first. Limit: 1–100; offset: non-negative integer. Add limit to offset for the next page; stop on an empty page.

curl

# Single
curl https://api.omertacall.com/v1/spam-checks \
  -H 'Authorization: Bearer YOUR_KEY' -H 'Idempotency-Key: spam-single-1' \
  -H 'Content-Type: application/json' -d '{"number":"+12025550142"}'
# Batch
curl https://api.omertacall.com/v1/spam-checks/batch \
  -H 'Authorization: Bearer YOUR_KEY' -H 'Idempotency-Key: spam-batch-1' \
  -H 'Content-Type: application/json' -d '{"numbers":["+12025550142","+14155550101"]}'
# Result and history
curl https://api.omertacall.com/v1/spam-checks/RESULT_ID -H 'Authorization: Bearer YOUR_KEY'
curl 'https://api.omertacall.com/v1/spam-checks?limit=50&offset=0' -H 'Authorization: Bearer YOUR_KEY'

JavaScript

const base = 'https://api.omertacall.com/v1/spam-checks';
const headers = {Authorization: 'Bearer YOUR_KEY', 'Content-Type': 'application/json'};
const single = await fetch(base, {method: 'POST', headers: {...headers, 'Idempotency-Key': 'spam-single-1'}, body: JSON.stringify({"number":"+12025550142"})}).then(r => r.json());
const batch = await fetch(base + '/batch', {method: 'POST', headers: {...headers, 'Idempotency-Key': 'spam-batch-1'}, body: JSON.stringify({"numbers":["+12025550142","+14155550101"]})}).then(r => r.json());
const result = await fetch(base + '/' + single.id, {headers}).then(r => r.json());
const history = await fetch(base + '?limit=50&offset=0', {headers}).then(r => r.json());

Python

import requests
base = 'https://api.omertacall.com/v1/spam-checks'
headers = {'Authorization': 'Bearer YOUR_KEY'}
single = requests.post(base, headers={**headers, 'Idempotency-Key': 'spam-single-1'}, json={"number":"+12025550142"}).json()
batch = requests.post(base + '/batch', headers={**headers, 'Idempotency-Key': 'spam-batch-1'}, json={"numbers":["+12025550142","+14155550101"]}).json()
result = requests.get(base + '/' + single['id'], headers=headers).json()
history = requests.get(base, headers=headers, params={'limit': 50, 'offset': 0}).json()

Response shape (unanswered example)

{
  "id": "nir_00000000-0000-4000-8000-000000000000",
  "kind": "spam",
  "status": "complete",
  "created_at": 1794484800,
  "charged": {
    "amount_minor": 500000,
    "currency": "USD",
    "scale": 6
  },
  "items": [
    {
      "id": "nic_00000000-0000-4000-8000-000000000001",
      "number": "+12025550142",
      "outcome": "unanswered",
      "charged": {
        "amount_minor": 500000,
        "currency": "USD",
        "scale": 6
      },
      "summary": {
        "verdict": null,
        "sources": [
          {
            "source": "Truecaller",
            "status": "unavailable",
            "classification": "unknown"
          }
        ]
      }
    }
  ]
}

Single and batch share this shape. Items retain input order; each has its own outcome and charge. History returns {"items":[RESULT],"limit":50,"offset":0}. Outcome: answered, partial, unanswered or unavailable. Every performed lookup is charged the flat price above, including an unanswered one; only a lookup that could not be performed (unavailable) is free. The request status is complete or unavailable.

HLR lookups

Connectivity: connected, switched_off, invalid or unknown; current network, ported, roaming and MCC-MNC when available.

$0.500000 per lookup performed; no charge if the lookup could not be performed.

POST /v1/hlr-lookups accepts {"number":"+12025550142"}. POST /v1/hlr-lookups/batch accepts {"numbers":["+12025550142","+14155550101"]}.

GET /v1/hlr-lookups/{id} accepts a request ID or item ID and returns the containing request. Other accounts' IDs and kind mismatches return 404. GET /v1/hlr-lookups?limit=50&offset=0 lists this service's completed results from the last 90 days, newest first. Limit: 1–100; offset: non-negative integer. Add limit to offset for the next page; stop on an empty page.

curl

# Single
curl https://api.omertacall.com/v1/hlr-lookups \
  -H 'Authorization: Bearer YOUR_KEY' -H 'Idempotency-Key: hlr-single-1' \
  -H 'Content-Type: application/json' -d '{"number":"+12025550142"}'
# Batch
curl https://api.omertacall.com/v1/hlr-lookups/batch \
  -H 'Authorization: Bearer YOUR_KEY' -H 'Idempotency-Key: hlr-batch-1' \
  -H 'Content-Type: application/json' -d '{"numbers":["+12025550142","+14155550101"]}'
# Result and history
curl https://api.omertacall.com/v1/hlr-lookups/RESULT_ID -H 'Authorization: Bearer YOUR_KEY'
curl 'https://api.omertacall.com/v1/hlr-lookups?limit=50&offset=0' -H 'Authorization: Bearer YOUR_KEY'

JavaScript

const base = 'https://api.omertacall.com/v1/hlr-lookups';
const headers = {Authorization: 'Bearer YOUR_KEY', 'Content-Type': 'application/json'};
const single = await fetch(base, {method: 'POST', headers: {...headers, 'Idempotency-Key': 'hlr-single-1'}, body: JSON.stringify({"number":"+12025550142"})}).then(r => r.json());
const batch = await fetch(base + '/batch', {method: 'POST', headers: {...headers, 'Idempotency-Key': 'hlr-batch-1'}, body: JSON.stringify({"numbers":["+12025550142","+14155550101"]})}).then(r => r.json());
const result = await fetch(base + '/' + single.id, {headers}).then(r => r.json());
const history = await fetch(base + '?limit=50&offset=0', {headers}).then(r => r.json());

Python

import requests
base = 'https://api.omertacall.com/v1/hlr-lookups'
headers = {'Authorization': 'Bearer YOUR_KEY'}
single = requests.post(base, headers={**headers, 'Idempotency-Key': 'hlr-single-1'}, json={"number":"+12025550142"}).json()
batch = requests.post(base + '/batch', headers={**headers, 'Idempotency-Key': 'hlr-batch-1'}, json={"numbers":["+12025550142","+14155550101"]}).json()
result = requests.get(base + '/' + single['id'], headers=headers).json()
history = requests.get(base, headers=headers, params={'limit': 50, 'offset': 0}).json()

Response shape (unanswered example)

{
  "id": "nir_00000000-0000-4000-8000-000000000000",
  "kind": "hlr",
  "status": "complete",
  "created_at": 1794484800,
  "charged": {
    "amount_minor": 500000,
    "currency": "USD",
    "scale": 6
  },
  "items": [
    {
      "id": "nic_00000000-0000-4000-8000-000000000001",
      "number": "+12025550142",
      "outcome": "unanswered",
      "charged": {
        "amount_minor": 500000,
        "currency": "USD",
        "scale": 6
      },
      "summary": {
        "status": "unknown",
        "network": null,
        "ported": null,
        "roaming": null,
        "mcc_mnc": null
      }
    }
  ]
}

Single and batch share this shape. Items retain input order; each has its own outcome and charge. History returns {"items":[RESULT],"limit":50,"offset":0}. Outcome: answered, partial, unanswered or unavailable. Every performed lookup is charged the flat price above, including an unanswered one; only a lookup that could not be performed (unavailable) is free. The request status is complete or unavailable.

Number reputation

Enable the explicit reputation_read scope on an API key in the Developer page. These reads are free and use the stored reputation checks for numbers assigned to your account.

List numbers

GET /v1/numbers?status=watch&limit=50

curl 'https://api.omertacall.com/v1/numbers?status=watch&limit=50' -H 'Authorization: Bearer YOUR_KEY'
curl 'https://api.omertacall.com/v1/numbers/%2B12025550142/reputation' -H 'Authorization: Bearer YOUR_KEY'

// JavaScript (Node.js): read numbers and verify an event's raw UTF-8 body
import {createHmac, timingSafeEqual} from 'node:crypto';
const headers = {Authorization: 'Bearer YOUR_KEY'};
const page = await fetch('https://api.omertacall.com/v1/numbers?limit=50', {headers}).then(r => r.json());
const detail = await fetch('https://api.omertacall.com/v1/numbers/%2B12025550142/reputation', {headers}).then(r => r.json());
const expected = Buffer.from('sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET).update(rawBody).digest('hex'));
const received = Buffer.from(signatureHeader);
if (received.length !== expected.length || !timingSafeEqual(received, expected)) throw new Error('Invalid signature');

# Python: read numbers and verify the raw bytes before parsing JSON
import os, requests, hmac, hashlib
headers = {'Authorization': 'Bearer YOUR_KEY'}
page = requests.get('https://api.omertacall.com/v1/numbers', headers=headers, params={'limit': 50}).json()
detail = requests.get('https://api.omertacall.com/v1/numbers/%2B12025550142/reputation', headers=headers).json()
expected = 'sha256=' + hmac.new(os.environ['WEBHOOK_SECRET'].encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature_header):
    raise ValueError('Invalid signature')

Each item includes number, status (clean, watch, flagged or not_checked), checked_at, next_check_at, inter_carrier_score (0–100 or null) and inter_carrier_band. Scores are null when unavailable or not enabled for your account. Follow next_cursor by passing it unchanged as the next URL-encoded cursor query parameter; null ends the list. Numbers may change during pagination. The per-key 60/minute limit shares the click-to-call window; 429 includes Retry-After.

Read one number

GET /v1/numbers/{e164}/reputation returns status, verdict, source labels/categories/report counts, inter-carrier score and the last 30 checks. A number outside your account returns 404.

Webhook signature

Status changes send number.reputation_changed with number, old_status, new_status and checked_at. Verify X-OmertaCall-Signature: sha256=<HMAC-SHA256(secret, rawBody)> using the secret shown once in the Developer page. Delivery is at least once. Deduplicate the Idempotency-Key header before applying an event. Acknowledge with any 2xx. Failed attempts retry after 60 seconds, doubling to one hour; delivery is disabled after 50 consecutive failures. Fix your endpoint and use Re-enable delivery on the Developer page. Reputation signatures cover only the raw body; call.completed signatures keep their timestamp-prefixed format.

const expected = 'sha256=' + hmacSha256Hex(rawRequestBody, webhookSecret)
// compare expected to X-OmertaCall-Signature with a constant-time comparison

Caller ID suggestions

Enable caller_id_read explicitly for each API key; existing, new and rotated keys start without it. GET /v1/caller-id/suggest accepts one to in E.164 (+ followed by 6–15 digits, first non-zero). Encode + as %2B. Only your currently assigned active numbers are eligible; flagged numbers are excluded from every result. Ranking: same country, longest matching digit prefix within that country, clean before watch before not_checked, then higher Inter-carrier score when enabled for your account. A prefix match is a locality heuristic, not an official area boundary. Unknown countries receive no locality preference. Clean means fresh flag-free evidence; watch means partial, stale or unknown answered evidence without flags; not_checked means no answered history in the current assignment. Scores are omitted when unavailable or customer score visibility is disabled. Last-24-hour usage is not used; remaining ties sort by number. The response contains number, reason and up to three distinct alternatives with number and reason. This is advisory at the time of the read and does not reserve a number or place a call. No charge. Limit: 60 requests per minute per key, in an independent fixed window. Admitted requests consume quota even if a dependency later fails. Every response uses Cache-Control: no-store. Errors: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 no_caller_id (no usable number), 429 rate_limited (Retry-After in seconds), 503 unavailable. Retrying after a lost response is safe but counts again and may return newer inventory or evidence. Retry 503 later; never fall back to a flagged number.

curl

# Set API_KEY in your shell to your scoped API key.
curl --fail-with-body 'https://api.omertacall.com/v1/caller-id/suggest?to=%2B4930999999' \
  -H "Authorization: Bearer ${API_KEY}"

JavaScript

const response = await fetch('https://api.omertacall.com/v1/caller-id/suggest?to=%2B4930999999', {
  headers: { Authorization: 'Bearer YOUR_API_KEY' }
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
console.log(result.number, result.reason, result.alternatives);

Python

import requests
response = requests.get(
    'https://api.omertacall.com/v1/caller-id/suggest?to=%2B4930999999',
    headers={'Authorization': 'Bearer YOUR_API_KEY'}, timeout=30)
response.raise_for_status()
result = response.json()
print(result['number'], result['reason'], result['alternatives'])

Example response

{
  "number": "+4930123456",
  "reason": "same prefix +4930, clean, score 82",
  "alternatives": []
}

Call statistics

GET/v1/stats

Enable stats_read explicitly for each API key; existing, new and rotated keys start without it. GET /v1/stats requires inclusive UTC from and to dates (YYYY-MM-DD), covering at most 31 days, and group_by=number, destination_country or day. Each group contains calls, answered, answer_rate, avg_duration in seconds across answered calls only, total_minutes from billed seconds, and cost in your account currency from your own call charges. Number groups also contain reputation_status (clean, watch, not_checked or flagged) and intercarrier_score (null when unavailable or score visibility is disabled). Results contain only your own data. Invalid, anonymous, empty, national-format or otherwise non-E.164 source numbers are combined into one number=unknown bucket; its reputation fields stay not_checked and null. Unknown destination mappings are grouped as Unknown. At most 1,000 complete groups are returned; narrower the range on 422 too_many_groups. Identical queries are cached for 5 minutes, while HTTP responses always use Cache-Control: no-store. Limit: 60 requests per minute per key. Errors: 400 invalid_request, 401 unauthorized, 403 forbidden, 422 too_many_groups, 429 rate_limited (see Retry-After), 503 unavailable. Reads are safe to retry.

curl

# Set API_KEY in your shell to an API key with stats_read enabled.
curl --fail-with-body 'https://api.omertacall.com/v1/stats?from=2026-11-01&to=2026-11-30&group_by=number' \
  -H "Authorization: Bearer ${API_KEY}"

JavaScript

const params = new URLSearchParams({
  from: '2026-11-01', to: '2026-11-30', group_by: 'number'
});
const response = await fetch(`https://api.omertacall.com/v1/stats?${params}`, {
  headers: { Authorization: 'Bearer YOUR_API_KEY' }
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
console.log(result.groups);

Python

import requests
response = requests.get(
    'https://api.omertacall.com/v1/stats',
    params={'from': '2026-11-01', 'to': '2026-11-30', 'group_by': 'number'},
    headers={'Authorization': 'Bearer YOUR_API_KEY'}, timeout=30)
response.raise_for_status()
print(response.json()['groups'])

Example response

{
  "from": "2026-11-01",
  "to": "2026-11-30",
  "group_by": "number",
  "groups": [
    {
      "number": "+4930123456",
      "calls": 2,
      "answered": 1,
      "answer_rate": 0.5,
      "avg_duration": 120,
      "total_minutes": 1,
      "cost": 0.5,
      "reputation_status": "clean",
      "intercarrier_score": 82
    }
  ]
}

Lines API

Enable lines_read to list your lines and lines_manage to change caller ID; each permission is opt-in for every existing, new or rotated key. GET /v1/lines returns id, name, registered, last_registration, user_agent (at most 255 characters) and current_caller_id. Nullable values are null; caller IDs use E.164 when usable. SIP credentials and internal routing data are never returned. PATCH /v1/lines/{id} accepts exactly {"caller_id":"+15551234567"}. The line must belong to you and the number must be currently assigned to you and not reputation-flagged. Another or unknown line returns 404; an unassigned number returns 422 number_not_assigned; a flagged number returns 409 number_flagged. A 200 response returns the caller ID read back after the change and records an audit event. A 503 can be uncertain after an attempted update: GET the line before retrying. Repeating the same caller ID is safe and converges to that value; no automatic rollback occurs. Each operation has an independent limit of 60 requests per minute per key. Admitted requests consume quota even if a dependency fails. Every response uses Cache-Control: no-store. Errors: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 number_flagged, 422 number_not_assigned, 429 rate_limited (Retry-After in seconds), 503 unavailable.

curl

# GET /v1/lines — set API_KEY in your shell to a key with lines_read.
curl --fail-with-body 'https://api.omertacall.com/v1/lines' \
  -H "Authorization: Bearer ${API_KEY}"

# PATCH /v1/lines/{id} — use a key with lines_manage and your own line id.
curl --fail-with-body -X PATCH 'https://api.omertacall.com/v1/lines/10' \
  -H "Authorization: Bearer ${API_KEY}" \
  -H 'Content-Type: application/json' \
  -d '{"caller_id":"+15551234567"}'

JavaScript

// GET /v1/lines (lines_read)
const headers = { Authorization: 'Bearer YOUR_API_KEY' };
const listed = await fetch('https://api.omertacall.com/v1/lines', { headers });
const inventory = await listed.json();
if (!listed.ok) throw new Error(inventory.error);
console.log(inventory.lines);

// PATCH /v1/lines/{id} (lines_manage)
const updated = await fetch('https://api.omertacall.com/v1/lines/10', {
  method: 'PATCH',
  headers: { ...headers, 'Content-Type': 'application/json' },
  body: JSON.stringify({ caller_id: '+15551234567' })
});
const result = await updated.json();
if (!updated.ok) throw new Error(result.error);
console.log(result.line);

Python

import requests
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

# GET /v1/lines (lines_read)
response = requests.get('https://api.omertacall.com/v1/lines',
    headers=headers, timeout=30)
response.raise_for_status()
print(response.json()['lines'])

# PATCH /v1/lines/{id} (lines_manage)
response = requests.patch('https://api.omertacall.com/v1/lines/10',
    headers=headers, json={'caller_id': '+15551234567'}, timeout=30)
response.raise_for_status()
print(response.json()['line'])

Example response

{
  "lines": [
    {
      "id": 10,
      "name": "OC1001",
      "registered": true,
      "last_registration": "2026-11-13T04:00:00.000Z",
      "user_agent": "Softphone",
      "current_caller_id": "+15551234567"
    }
  ]
}

Number requests

POST/v1/number-requests GET/v1/number-requests GET/v1/number-requests/{id}

Enable numbers_request explicitly for each API key; existing, new and rotated keys start without it. POST /v1/number-requests submits a request for new phone numbers to the OmertaCall team. Fields: country (required, ISO 3166-1 alpha-2), area_or_prefix (optional, area code or prefix hint), quantity (integer 1–500, default 1), type (local, mobile or tollfree), notes (optional free-text). The team is notified by email and fulfils the request manually; the response returns an id and status received. No numbers are ordered from any external source — every request is reviewed and fulfilled by a human. GET /v1/number-requests lists your pending and completed requests, newest first. GET /v1/number-requests/{id} returns the status (received, in_progress, fulfilled, declined) and notes for one request. Every response uses Cache-Control: no-store. Limits: 60 requests per minute per key, sharing the existing per-key rate window. Errors: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 429 rate_limited (Retry-After in seconds), 503 unavailable.

curl

# Request new numbers
curl --fail-with-body https://api.omertacall.com/v1/number-requests \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "DE",
    "area_or_prefix": "30",
    "quantity": 5,
    "type": "local",
    "notes": "Berlin office expansion"
  }'

# List your number requests
curl https://api.omertacall.com/v1/number-requests \
  -H "Authorization: Bearer ${API_KEY}"

# Get one request status
curl https://api.omertacall.com/v1/number-requests/42 \
  -H "Authorization: Bearer ${API_KEY}"

JavaScript

// Request new numbers
const request = await fetch('https://api.omertacall.com/v1/number-requests', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    country: 'DE',
    area_or_prefix: '30',
    quantity: 5,
    type: 'local',
    notes: 'Berlin office expansion'
  })
});
const result = await request.json();
console.log(result.id, result.status);

// List your number requests
const list = await fetch('https://api.omertacall.com/v1/number-requests', {
  headers: { Authorization: 'Bearer YOUR_API_KEY' }
});
const requests = await list.json();
console.log(requests.items);

// Get one request status
const status = await fetch('https://api.omertacall.com/v1/number-requests/42', {
  headers: { Authorization: 'Bearer YOUR_API_KEY' }
});
const detail = await status.json();
console.log(detail.status);

Python

import requests

# Request new numbers
response = requests.post(
    'https://api.omertacall.com/v1/number-requests',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
    json={
        'country': 'DE',
        'area_or_prefix': '30',
        'quantity': 5,
        'type': 'local',
        'notes': 'Berlin office expansion'
    },
    timeout=30
)
response.raise_for_status()
result = response.json()
print(result['id'], result['status'])

# List your number requests
list_resp = requests.get(
    'https://api.omertacall.com/v1/number-requests',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
    timeout=30
)
list_resp.raise_for_status()
requests_data = list_resp.json()
print(requests_data['items'])

# Get one request status
status_resp = requests.get(
    'https://api.omertacall.com/v1/number-requests/42',
    headers={'Authorization': 'Bearer YOUR_API_KEY'},
    timeout=30
)
status_resp.raise_for_status()
detail = status_resp.json()
print(detail['status'])

Example response (POST)

{
  "id": 42,
  "status": "received"
}

Example response (GET list)

{
  "items": [
    {
      "id": 42,
      "country": "DE",
      "area_or_prefix": "30",
      "quantity": 5,
      "type": "local",
      "notes": "Berlin office expansion",
      "status": "received",
      "created_at": 1794484800
    }
  ]
}

Monitor a live call

POST/v1/calls/{call_id}/monitor

Requires the explicit supervise API-key scope. POST /v1/calls/{call_id}/monitor rings one of your own lines and attaches it to your own live call in listen, whisper, or join mode. The limit is 60 requests per API key and 60 per client per minute. Unknown, foreign, ended, or unreachable calls and lines are refused without a confirmed action. A 503 uncertain response means the switch acknowledgement was lost; reconcile before retrying because retrying may create a second monitor leg. Responses are private and no-store.

FieldDescription
modelisten hears only, whisper speaks to the agent, join joins both legs.
supervisor_line_idNumeric id of one of your own SIP lines.

curl

curl -X POST https://api.omertacall.com/v1/calls/CALL_ID/monitor \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"mode":"listen","supervisor_line_id":101}'

JavaScript

const response = await fetch('https://api.omertacall.com/v1/calls/CALL_ID/monitor', {
  method: 'POST',
  headers: {Authorization: 'Bearer ' + apiKey, 'Content-Type': 'application/json'},
  body: JSON.stringify({mode: 'whisper', supervisor_line_id: 101}),
});
const result = await response.json();

Python

import requests
response = requests.post('https://api.omertacall.com/v1/calls/CALL_ID/monitor',
  headers={'Authorization': 'Bearer ' + api_key},
  json={'mode': 'join', 'supervisor_line_id': 101})
result = response.json()

A confirmed response is {"ok":true,"call_id":"CALL_ID","mode":"listen","supervisor_line_id":101}. A 503 uncertain response means the acknowledgement was lost; reconcile before retrying.

Client webhooks

Enable webhooks_manage explicitly for each API key in the portal. Existing and rotated keys do not acquire it automatically. All webhook routes share a 60-request/minute per-key limit and use Cache-Control: no-store.

Up to three public HTTPS endpoints per client, including disabled endpoints. Private, loopback, link-local and other non-public addresses, credentials, fragments and redirects are rejected. DNS is checked on creation and every delivery.

POST creation and test require Idempotency-Key, retained for 24 hours. Reuse the same key only for the identical action. The secret is returned only on the first creation response, never on list or replay. If that response is lost, list, deliberately delete, and recreate with a new key. A blind new-key retry can create an extra subscription.

Each body is {id,type,created_at,data}; id is a stable event ID, created_at is an ISO UTC timestamp or null if unavailable. Events: call.started, call.answered, call.ended, recording.ready, number.flagged, number.reputation_changed, balance.low. webhook.test is synthetic and need not be selected.

Call data always includes call_id, from, to, direction, duration, disposition, cost and currency. Unavailable fields are null. Duration is seconds; cost is your own USD charge as a decimal string. Inbound cost is null when unavailable. Call IDs are opaque webhook identities, distinct from click-to-call IDs. Lifecycle events may arrive after completion; answered is emitted only when proved. Ordering across calls/endpoints is not guaranteed.

recording.ready contains call_id and url and is sent only for answered calls whose recording becomes available within 24 hours of the call ending. Use the authenticated GET /v1/webhooks/calls/{callId}/recording route with webhooks_manage. Recording ownership is rechecked. Number events contain only your number and customer reputation summary. balance.low contains balance and threshold as decimal strings with six places (the balance is rounded half away from zero) and currency USD; default threshold is 10, with one event per low-balance episode. Recovery or a changed threshold starts a new episode.

OC-Signature is t=<Unix seconds>,v1=<hex HMAC-SHA256(secret,"<timestamp>.<exact body>")>. Use the displayed secret as UTF-8 text, not hex-decoded bytes. Verify raw bytes, strict header syntax and a five-minute clock tolerance before parsing. Store event IDs durably and atomically with business effects before acknowledging; do not use a process-local set.

Delivery is at least once. Retry delays are min(3600,30*2**min(failureNumber-1,7)) seconds, for 24 hours from enqueue. Each retry has the same body/id and a fresh signature timestamp. A lost HTTP or result acknowledgment may repeat an already applied effect; receiver deduplication is required. Return 2xx only after durable acceptance.

Fifty consecutive failures auto-disable an endpoint; a successful delivery resets the count. Fix the receiver and re-enable it. Re-enable never revives expired work. Deleting cancels pending work but cannot recall an in-flight request. Deletion and re-enable can be safely retried. Last 100 attempts show success, failure or uncertain acknowledgment; queued test events are not proof of delivery.

This API is separate from existing call.completed callbacks and their X-OmertaCall-* headers and secret. No subscriptions or scopes are imported from those callbacks.

Errors: 400 invalid request/target/threshold; 401 unauthorized; 403 scope_required; 404 not_found (including foreign resources); 409 endpoint_limit, duplicate_url, idempotency_conflict or endpoint_disabled; 413 request_too_large; 429 rate_limited with Retry-After seconds; 503 webhooks_unavailable or unavailable.

Manage subscriptions

curl

# Enable webhooks_manage for this key in the portal first.
# Use a new action key for each intentional create; retain it for retries (24h).
curl https://api.omertacall.com/v1/webhooks -H "Authorization: Bearer $API_KEY"
curl -X POST https://api.omertacall.com/v1/webhooks \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-endpoint-1" \
  -d '{"url":"https://your-app.example/events","events":["call.ended","balance.low"]}'
# Store the returned secret securely now: it is returned only once.
curl -X POST "https://api.omertacall.com/v1/webhooks/$ENDPOINT_ID/test" \
  -H "Authorization: Bearer $API_KEY" -H "Idempotency-Key: test-event-1"
curl "https://api.omertacall.com/v1/webhooks/$ENDPOINT_ID/deliveries" -H "Authorization: Bearer $API_KEY"
curl https://api.omertacall.com/v1/webhooks/settings -H "Authorization: Bearer $API_KEY"
curl -X PUT https://api.omertacall.com/v1/webhooks/settings \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" -d '{"balance_low_threshold":"10.00"}'
curl -X POST "https://api.omertacall.com/v1/webhooks/$ENDPOINT_ID/reenable" -H "Authorization: Bearer $API_KEY"
curl -X DELETE https://api.omertacall.com/v1/webhooks -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d "{\"id\":\"$ENDPOINT_ID\"}"

JavaScript

const base = 'https://api.omertacall.com/v1/webhooks';
async function request(path = '', method = 'GET', body, actionKey) {
  const response = await fetch(base + path, { method, headers: {
    Authorization: 'Bearer ' + process.env.API_KEY, 'Content-Type': 'application/json',
    ...(actionKey ? { 'Idempotency-Key': actionKey } : {})
  }, body: body === undefined ? undefined : JSON.stringify(body) });
  if (!response.ok) throw new Error('Webhook API request failed: ' + response.status);
  return response.status === 204 ? undefined : response.json();
}
const created = await request('', 'POST', { url: 'https://your-app.example/events', events: ['call.ended'] }, 'create-endpoint-1');
// Securely store created.secret now. Never log it. No automatic creation retry.
const id = encodeURIComponent(created.endpoint.id);
await request();
await request('/' + id + '/test', 'POST', undefined, 'test-event-1'); // queued, not delivered
await request('/' + id + '/deliveries');
await request('/settings');
await request('/settings', 'PUT', { balance_low_threshold: '10.00' });
await request('/' + id + '/reenable', 'POST');
await request('', 'DELETE', { id: created.endpoint.id });

Python

import os, requests
from urllib.parse import quote
base = 'https://api.omertacall.com/v1/webhooks'
def request(path='', method='GET', body=None, action_key=None):
    headers = {'Authorization': 'Bearer ' + os.environ['API_KEY']}
    if action_key: headers['Idempotency-Key'] = action_key
    response = requests.request(method, base + path, headers=headers, json=body, timeout=15)
    response.raise_for_status()
    return None if response.status_code == 204 else response.json()
created = request(method='POST', body={'url': 'https://your-app.example/events', 'events': ['call.ended']}, action_key='create-endpoint-1')
# Securely store created['secret'] now. Never log it. No automatic creation retry.
id = quote(created['endpoint']['id'], safe='')
request()
request('/' + id + '/test', 'POST', action_key='test-event-1') # queued, not delivered
request('/' + id + '/deliveries')
request('/settings')
request('/settings', 'PUT', {'balance_low_threshold': '10.00'})
request('/' + id + '/reenable', 'POST')
request(method='DELETE', body={'id': created['endpoint']['id']})

Verify signatures

JavaScript

// Node.js. Pass the untouched request body Buffer, never JSON reserialization.
const { createHmac, timingSafeEqual } = require('node:crypto');
function verifyWebhook(raw, header, secret, now = Math.floor(Date.now() / 1000)) {
  const match = /^t=([0-9]{1,12}),v1=([0-9a-f]{64})$/.exec(header || '');
  if (!match || !Number.isSafeInteger(Number(match[1])) || Math.abs(now - Number(match[1])) > 300) return false;
  const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(match[1] + '.').update(raw).digest();
  return timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}
// Reject unless verifyWebhook(rawBody, request.headers['oc-signature'], secret).
// Only then parse JSON. In one durable transaction, insert event.id under a UNIQUE
// constraint and apply your business effect (or insert your own outbox work).
// A duplicate event.id returns 2xx without repeating the effect.

Python

import hashlib, hmac, re, time

def verify_webhook(raw: bytes, header: str, secret: str, now=None) -> bool:
    # raw is the original HTTP body bytes, not serialized parsed JSON.
    match = re.fullmatch(r't=([0-9]{1,12}),v1=([0-9a-f]{64})', header or '')
    if not match: return False
    now = int(time.time()) if now is None else now
    if abs(now - int(match[1])) > 300: return False
    expected = hmac.new(secret.encode('utf-8'), match[1].encode('ascii') + b'.' + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, match[2])
# Reject unless verify_webhook(raw_body, signature_header, secret).
# Then parse JSON. Atomically insert event['id'] under a UNIQUE constraint and
# apply your business effect (or insert your own outbox work). Duplicates return
# 2xx without repeating effects.

PHP

<?php
function verify_webhook(string $raw, string $header, string $secret, ?int $now = null): bool {
    if (!preg_match('/\At=([0-9]{1,12}),v1=([0-9a-f]{64})\z/', $header, $match)) return false;
    if (abs(($now ?? time()) - (int)$match[1]) > 300) return false;
    return hash_equals(hash_hmac('sha256', $match[1] . '.' . $raw, $secret), $match[2]);
}
// $raw = file_get_contents('php://input'); // read once, before JSON parsing
// Reject unless verify_webhook($raw, $_SERVER['HTTP_OC_SIGNATURE'] ?? '', $secret).
// Then parse JSON. In one durable transaction, insert event id with a UNIQUE
// constraint and apply your business effect (or enqueue your own outbox work).
// A duplicate event id returns 2xx without repeating the effect.