Wayso Audio API
Wayso Audio turns sales call recordings into structured intelligence. Upload an audio file — the API transcribes it in the language it was spoken in, analyzes it, and returns a clean JSON object with lead score, sentiment, deal probability, objections, next steps, and more.
Base URL: https://wayso-audio-backend.fly.dev
All endpoints return JSON. All request bodies are multipart/form-data (for uploads) or application/json.
Authentication
Every request must carry a Bearer token. Generate one from the API keys page in the dashboard, or via the API keys endpoint below.
curl https://wayso-audio-backend.fly.dev/v1/calls \
-H "Authorization: Bearer wa_live_YOUR_KEY_HERE"Tokens are prefixed wa_live_. Keep them secret — they grant full access to your org's data.
Connected apps
An API key automates your own organisation. A connected app is different: it is software that many Wayso Audio customers connect, each granting it access to their own account. No developer can ask every customer to paste an admin key, so apps use OAuth 2.0 with PKCE instead.
| Field | Type | Description | |
|---|---|---|---|
| API key | wa_live_… | Your own scripts. Admin-scoped, never expires, acts as your org. | |
| Connected app | OAuth | Software others connect. Scoped to what it does, revocable from either side, attributed on the bill. |
Clients are registered by hand today — there is no self-serve portal. Email the operator with your app name, redirect URI and the scopes you need, and you will receive a client_id and client_secret.
Test mode. A client can be registered in test mode, where calls:place emits the full event sequence against a simulated call: no carrier, no wallet movement, no real phone. Build against that rather than against a customer’s balance.
The connect flow
Authorization code with PKCE. Send the customer to /oauth/authorize; they sign in to Wayso Audio, choose which organisation to connect and approve the scopes; you exchange the returned code for tokens, server to server.
1. Send them to authorize
| Field | Type | Description | |
|---|---|---|---|
| client_id | string | required | Your client id. |
| redirect_uri | string | required | Must match a registered URI EXACTLY — no prefixes, no trailing-slash tolerance. |
| response_type | string | required | Always "code". |
| scope | string | required | Space-separated. See Scopes below. |
| state | string | required | Returned unchanged. Your CSRF defence — compare it on the way back. |
| code_challenge | string | required | base64url(SHA-256(verifier)). Required for every client, confidential or not. |
| code_challenge_method | string | required | S256 only. "plain" is refused. |
import { createHash, randomBytes } from 'crypto';
// Keep the verifier on YOUR server. It must never reach the browser —
// that is the whole point: a code intercepted in the redirect is useless
// without it.
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(24).toString('base64url');
// Store { verifier, state } against this attempt, server-side.
const url = new URL('https://wayso-audio-backend.fly.dev/oauth/authorize');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', 'https://your.app/callback');
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'calls:place calls:read webhooks:manage');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
// Open it in a popup, or redirect.2. They approve
The consent screen names your app, lists each scope in plain words, lets them pick which organisation to connect, and — when you asked for calls:place — asks for a daily spending limit. Approving redirects to your redirect_uri with code and state; denying returns error=access_denied.
If you opened a popup, it posts the result to the opener and closes. Check the message origin before trusting it — it arrives from https://wayso-audio-web.fly.dev, not from the API host.
// window.addEventListener('message', …)
{ "source": "wayso-audio-oauth",
"redirect_to": "https://your.app/callback?code=wacode_…&state=…" }3. Exchange the code
Server to server, with your client secret. The code is single-use, lives 60 seconds, and is bound to the client, the redirect and the PKCE challenge — so one intercepted in a redirect cannot be spent by whoever caught it.
{ "grant_type": "authorization_code",
"code": "wacode_…",
"redirect_uri": "https://your.app/callback",
"code_verifier": "the verifier you stored",
"client_id": "wac_…",
"client_secret": "was_…" }{ "access_token": "wat_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "wrt_…",
"scope": "calls:place calls:read webhooks:manage",
"org": { "id": "org_registry:…", "name": "Heera Bangles" },
"rep": "rep:abc" // the line the approver uses; absent on an org-only grant
}rep is the agent record of whoever approved, present when you asked for softphone. Send it as rep when placing a call so their line rings. Without it the call reaches the customer with nobody to bridge.
Scopes
Ask for the narrowest set that does the job — every scope appears on the customer’s consent screen, and asking for more than the feature needs is how an integration gets declined. Scopes do not imply one another: calls:place does not grant calls:read.
| Field | Type | Description | |
|---|---|---|---|
| calls:place | org | Start outbound calls. Spends the customer’s balance — see the daily cap. | |
| calls:read | org | Read call records: who, when, how long, outcome, transcript, recording link. | |
| recordings:read | org | Fetch a recording directly. Separate because knowing a call happened and hearing it are different intrusions. | |
| numbers:read | org | List the org’s phone numbers. | |
| numbers:mask | org | Connect two people through a virtual number so neither sees the other’s. | |
| webhooks:manage | org | Register and remove your own event endpoints. | |
| softphone | user | Act as the approving person’s line. Granted per person, not per organisation. |
org vs user. An org scope is approved once by an admin for the whole business. softphone is approved by each person for themselves, because a line belongs to one human — an org-wide token cannot say whose it is. Request both together and one approval covers the approver; other agents link their own line afterwards.
Tokens & refresh
Access tokens last an hour; refresh tokens last 90 days. Tokens are opaque — treat them as strings, never parse them.
{ "grant_type": "refresh_token",
"refresh_token": "wrt_…",
"client_id": "wac_…",
"client_secret": "was_…" }Refresh tokens rotate. Store the new one before you use the new access token.
Each refresh returns a new refresh token and retires the old one. Presenting a spent refresh token means it leaked, so the entire grant is revoked rather than another token issued — the customer sees your app as disconnected. Writing the new token to storage before using the access token is what prevents losing a connection to a crash mid-refresh.
A refresh never widens scope. The grant decides; a scope parameter here is ignored.
Revoking
{ "token": "wat_… or wrt_…", "client_id": "wac_…", "client_secret": "was_…" }Revokes the whole connection, not one string. Always answers 200, even for a token that never existed — so this endpoint cannot be used to test whether a token is real. The customer can also disconnect you from their own settings; handle grant_revoked by stopping and prompting them to reconnect.
Placing a call
Requires calls:place.
| Field | Type | Description | |
|---|---|---|---|
| to | string | required | The number to dial, E.164. |
| rep | string | optional | Whose line to bridge — the `rep` from your token response. Omit it and the customer is connected to nobody. |
| external_ref | string | optional | Your own record id. Echoed on every event about this call, so you can attach it without us knowing what your records are. |
| title | string | optional | Shown in the customer’s own call list. |
| mask | boolean | optional | Present a virtual number as caller ID. Needs numbers:mask. |
| analysis | none | transcript | full | optional | How much analysis this call is worth. You may ask for less than the line allows and never more — see Webhooks → asking for less analysis. |
// 202 Accepted
{ "call": { "id": "call:7k2…", … }, "status": "ringing" }Today the customer’s number is dialled first and the rep is bridged when they answer. Four refusals worth handling specifically: wallet_empty, spend_cap_reached, kyc_blocked and no_number — each is something the customer can act on, so pass the message through rather than flattening it to “call failed”.
Inbound calls carry your ref too
A call you place carries external_ref because you stated it. A call from the same customer ringing in carries it as well, taken from the owner row you pushed at PUT /v1/routing/owners — so you can place an inbound call on the right record without matching the phone number, which is wrong whenever two people share a number, somebody changes theirs, or a contact is edited after the call.
It needs two things: the owner row must carry an external_ref, and the customer must have switched on “can use customer data on these calls” for that number. Without the grant the ref is withheld — the same rule that governs whether ring_owner can identify a caller at all, applied to the id as well as the name.
Reading one call back
Requires calls:read. Returns the transcript, the analysis and a short-lived signed recording URL.
Do not store that URL — it expires, and a saved copy becomes a dead link. Store the call id and fetch this when somebody asks to listen. That also keeps you from copying kilobytes of transcript per call into your own database for something almost nobody opens.
Webhooks
Register your own endpoint with webhooks:manage — the customer never copies a URL anywhere.
{ "url": "https://your.app/hooks/wayso-audio",
"events": ["call.ringing", "call.answered", "call.ended",
"call.recording", "call.analysed"] }
// 201 — the secret is shown once and never again
{ "id": "webhook:…", "secret": "whsec_…", "events": [...] }https only. Re-registering the same URL replaces it and issues a fresh secret, so registering on every boot does not accumulate endpoints.
The payload
POST https://your.app/hooks/wayso-audio
Wayso-Audio-Signature: t=1760000000,v1=9f86d0818…
Wayso-Audio-Event-Id: evt_01J9Z…
{ "event": "call.ended",
"id": "evt_01J9Z…",
"at": "2026-10-13T06:14:22Z",
"call": {
"id": "call:7k2…",
"external_ref": "contact:9vq…",
"direction": "outbound",
"from": "+918035xxxxxx",
"to": "+9198765xxxxx",
"rep": "rep:abc",
"status": "completed",
"duration_seconds": 186,
"outcome": "answered",
"via_client": "wac_…"
} }call.analysed — subscribe to this one
call.recording deliberately promises nothing about the transcript: analysis begins when that event fires and finishes minutes later, so an app told “transcript ready” there would fetch an empty one. call.analysed is what closes the loop. It fires once per call — including for a call that got no AI at all — and names what was actually produced.
{ "event": "call.analysed",
"call": {
"id": "call:7k2…",
"external_ref": "contact:9vq…",
"analysis": {
"tier": "transcript", // none | transcript | full
"requested_tier": "full", // what was asked for
"skip_reason": "too_short_to_score:34s",
"transcript": true, // worth fetching the transcript
"scored": false // no lead score, sentiment or coaching
} } }Do not poll GET /v1/calls/:id waiting for a transcript to appear. A call can be recorded and deliberately never analysed — the customer sets that per line, and short calls, unanswered calls and voicemail are never scored whatever the setting — so a poller cannot tell “not yet” from “never” and will wait forever on a call that is already finished. Wait for this event, then fetch once.
tier tells you which fields are worth reading. scored: false is a normal, configured state, not a failure — build for it rather than showing your users an error.
Asking for less analysis
Pass analysis when you place a call — on POST /v1/telephony/calls or on POST /v1/telephony/dial-intent — to request none, transcript or full. Same field on POST /v1/calls for an upload.
You may ask for less than the line allows and never more. The balance being spent is the customer’s, so the ceiling is theirs to raise — the same rule as the scope ceiling on your grant. GET /v1/routing/numbers reports each line’s ceiling as analysis, so you can show a support desk integration asking for transcripts only, and know in advance that scores will not be there.
Verifying a delivery
HMAC-SHA256 over "{timestamp}.{raw body}". You have no session and no key here — the signature is the authentication, so verify it on every delivery.
import { createHmac, timingSafeEqual } from 'crypto';
// The RAW body. A parsed-and-re-serialised object has different bytes and
// the signature will never match — so read the body before any JSON parser
// touches it.
function verify(raw, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=').map((x) => x.trim())));
const ts = Number(parts.t);
// Reject a stale timestamp, or a captured delivery can be replayed later.
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = createHmac('sha256', secret)
.update(\`\${ts}.\${raw}\`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
return a.length === b.length && timingSafeEqual(a, b);
}Rules that matter
| Field | Type | Description | |
|---|---|---|---|
| Answer fast | 5s timeout | Return 200 and do your work afterwards. Anything slower is treated as a failure and retried. | |
| Deduplicate | Wayso-Audio-Event-Id | Stable across every retry. The same event may arrive more than once; a unique index on this id is the simplest defence. | |
| Do not assume order | — | A retried `ringing` can land after `ended`. Treat events as timestamped facts, not a sequence. | |
| Retries | 8 over ~6h | Exponential with jitter. After that the delivery is abandoned and the event is lost unless you poll GET /v1/calls. |
GET /v1/webhooks/deliveries shows every attempt we made, with status and last error — so “did you even try” is a question you can answer without asking us.
Upload a call
Upload an audio file. The call record is created immediately (status: processing), and analysis runs in the background. Poll /v1/calls/:id until status is done.
| Field | Type | Description | |
|---|---|---|---|
| audio | file | required | Audio file. Supported: mp3, wav, m4a, ogg, webm. Max 100 MB. |
| title | string | optional | Human-readable label. Defaults to the filename. |
| prospect_id | string | optional | Link the call to a prospect record (e.g. "prospect:abc123"). See Prospects. |
| rep_id | string | optional | Link the call to a rep record (e.g. "rep:xyz789"). See Reps. |
curl -X POST https://wayso-audio-backend.fly.dev/v1/calls \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY" \
-F "audio=@./sales-call.mp3" \
-F "title=Acme Q3 follow-up"Returns HTTP 202 with the call object.
{
"call": {
"id": "call:abc123",
"title": "Acme Q3 follow-up",
"status": "processing",
"audio_filename": "sales-call.mp3",
"audio_bytes": 4812000,
"created_at": "2026-07-04T10:00:00Z"
}
}Get call + analysis
Fetch a single call. When status is done, the analysis object is included. Poll every 3–5 seconds until done (analysis typically takes 15–45 seconds).
curl https://wayso-audio-backend.fly.dev/v1/calls/call:abc123 \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY"{
"call": {
"id": "call:abc123",
"title": "Acme Q3 follow-up",
"status": "done",
"transcript": "नमस्ते, मैं Acme से बोल रहा हूँ...",
"audio_url": "https://...",
"analysis": {
"prospect": { "name": "Rahul Sharma", "company": "Acme Corp", "role": "Procurement Head" },
"lead_score": 78,
"sentiment": "positive",
"deal_probability": "high",
"qualification": { "budget": true, "authority": true, "need": true, "timeline": "Q3" },
"signals": ["mentioned budget approval", "asked about integration timeline"],
"objections": ["pricing too high vs. competitor", "needs IT sign-off"],
"rep_performance": { "score": 72, "strengths": ["good rapport"], "improvements": ["missed upsell opportunity"] },
"next_steps": ["Send proposal by Friday", "Schedule demo with IT team"],
"summary": "Warm lead with high deal probability. Budget approved, awaiting IT sign-off."
}
}
}List calls
| Field | Type | Description | |
|---|---|---|---|
| status | string | optional | Filter by status: pending, processing, done, failed |
| limit | number | optional | Page size. Default 20, max 100. |
| offset | number | optional | Pagination offset. Default 0. |
curl "https://wayso-audio-backend.fly.dev/v1/calls?status=done&limit=10" \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY"{ "calls": [ { "id": "call:abc123", "title": "...", "status": "done", ... }, ... ] }Delete a call
Permanently deletes the call record, audio file, transcript, and analysis. Irreversible.
curl -X DELETE https://wayso-audio-backend.fly.dev/v1/calls/call:abc123 \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY"{ "message": "Call deleted" }Stats
Aggregate stats for your org — call counts by status, average interest signal, and the spread of interest levels.
curl https://wayso-audio-backend.fly.dev/v1/calls/stats \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY"{
"by_status": [
{ "status": "done", "total": 142 },
{ "status": "processing", "total": 3 }
],
"avg_lead_score": 64.2,
"by_deal_probability": [
{ "deal_probability": "high", "total": 48 },
{ "deal_probability": "medium", "total": 61 },
{ "deal_probability": "low", "total": 33 }
]
}Prospects
Optional CRM records for the people you call. Link calls to a prospect to build per-prospect call history. Use external_id to store your own CRM's ID so you can map records between systems. Nothing here is required.
List all prospects, each with a calls_count.
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Prospect name. |
| company | string | optional | Company. |
| phone | string | optional | Phone number. |
| string | optional | Email address. | |
| external_id | string | optional | Your CRM/system ID for this prospect. |
| notes | string | optional | Freeform notes. |
curl -X POST https://wayso-audio-backend.fly.dev/v1/prospects \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Rahul Sharma","company":"Acme Corp","external_id":"crm_9921"}'{
"prospect": {
"id": "prospect:abc123",
"name": "Rahul Sharma",
"company": "Acme Corp",
"external_id": "crm_9921",
"created_at": "2026-07-04T10:00:00Z"
}
}Returns the prospect plus calls — every call linked to them.
Update any field (send only the fields you want to change).
Deletes the prospect and unlinks it from any calls (the calls themselves are kept).
Reps
The people from your org who take the calls. Link a call to a rep to track performance (call quality, talk balance, calls handled). Same optional external_id for CRM/HR mapping.
List all reps, each with a calls_count.
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Rep name. |
| string | optional | Email address. | |
| external_id | string | optional | Your CRM/HR ID for this rep. |
| active | boolean | optional | Whether the rep is active. Defaults to true. |
curl -X POST https://wayso-audio-backend.fly.dev/v1/reps \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Priya Nair","email":"priya@acme.com","external_id":"emp_204"}'{
"rep": {
"id": "rep:xyz789",
"name": "Priya Nair",
"email": "priya@acme.com",
"external_id": "emp_204",
"active": true,
"created_at": "2026-07-04T10:00:00Z"
}
}Returns the rep plus calls and a stats object (avg_lead_score, analysed).
Link calls to CRM
A call can optionally point to one prospect and one rep. Set them at upload time (prospect_id / rep_id in the upload body), or change them later on an existing call. Send an empty string to clear a link.
| Field | Type | Description | |
|---|---|---|---|
| prospect_id | string | optional | Prospect record ID, or "" to unlink. |
| rep_id | string | optional | Rep record ID, or "" to unlink. |
| title | string | optional | Rename the call. |
curl -X PATCH https://wayso-audio-backend.fly.dev/v1/calls/call:abc123 \
-H "Authorization: Bearer $WAYSO_AUDIO_KEY" \
-H "Content-Type: application/json" \
-d '{"prospect_id":"prospect:abc123","rep_id":"rep:xyz789"}'When you fetch a call, linked prospect and rep are returned as full objects.
API keys
List keys
Session auth required (dashboard use). Returns all keys for your org without exposing the plaintext.
Create a key
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Human label, e.g. "Production CRM" |
Returns the plaintext key once. Store it immediately — it cannot be retrieved again.
{ "key": "wa_live_abc...", "prefix": "wa_live_abc…" }Revoke a key
Immediately invalidates the key. Any requests using it will return 401.
Analysis schema
The analysis object returned on a completed call has the following fields:
| Field | Type | Description | |
|---|---|---|---|
| prospect | object | Detected prospect details: name, company, role, phone, email (if mentioned). | |
| lead_score | number | 0–100 interest signal — how interested the customer sounded, read from their words and tone. Not a prediction that a deal will close: a recording has no way to know that. Report real outcomes back via /v1/outcomes and we calibrate against them. | |
| sentiment | string | "positive" | "neutral" | "negative" — overall prospect sentiment. | |
| deal_probability | string | "high" | "medium" | "low" — likelihood of deal closing. | |
| qualification | object | BANT: { budget: bool, authority: bool, need: bool, timeline: string }. | |
| signals | string[] | Positive buying signals detected in the conversation. | |
| objections | string[] | Objections raised by the prospect. | |
| rep_performance | object | { score: 0–100, strengths: string[], improvements: string[] }. | |
| next_steps | string[] | Concrete action items extracted from the call. | |
| summary | string | 2–3 sentence summary of the call outcome. |
Errors
All errors return JSON with an error field.
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid field |
| 401 | Missing or invalid Bearer token |
| 403 | Authenticated but not authorized (wrong org, IP blocked) |
| 404 | Resource not found |
| 409 | Conflict — e.g. email already registered |
| 413 | File too large (max 100 MB) |
| 500 | Internal server error |
{ "error": "No active organization" }