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.

FieldTypeDescription
API keywa_live_…Your own scripts. Admin-scoped, never expires, acts as your org.
Connected appOAuthSoftware 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

GEThttps://wayso-audio-backend.fly.dev/oauth/authorize
FieldTypeDescription
client_idstringrequiredYour client id.
redirect_uristringrequiredMust match a registered URI EXACTLY — no prefixes, no trailing-slash tolerance.
response_typestringrequiredAlways "code".
scopestringrequiredSpace-separated. See Scopes below.
statestringrequiredReturned unchanged. Your CSRF defence — compare it on the way back.
code_challengestringrequiredbase64url(SHA-256(verifier)). Required for every client, confidential or not.
code_challenge_methodstringrequiredS256 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.

POSThttps://wayso-audio-backend.fly.dev/oauth/token
{ "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.

FieldTypeDescription
calls:placeorgStart outbound calls. Spends the customer’s balance — see the daily cap.
calls:readorgRead call records: who, when, how long, outcome, transcript, recording link.
recordings:readorgFetch a recording directly. Separate because knowing a call happened and hearing it are different intrusions.
numbers:readorgList the org’s phone numbers.
numbers:maskorgConnect two people through a virtual number so neither sees the other’s.
webhooks:manageorgRegister and remove your own event endpoints.
softphoneuserAct 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.

POSThttps://wayso-audio-backend.fly.dev/oauth/token
{ "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

POSThttps://wayso-audio-backend.fly.dev/oauth/revoke
{ "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

POSThttps://wayso-audio-backend.fly.dev/v1/telephony/calls

Requires calls:place.

FieldTypeDescription
tostringrequiredThe number to dial, E.164.
repstringoptionalWhose line to bridge — the `rep` from your token response. Omit it and the customer is connected to nobody.
external_refstringoptionalYour own record id. Echoed on every event about this call, so you can attach it without us knowing what your records are.
titlestringoptionalShown in the customer’s own call list.
maskbooleanoptionalPresent a virtual number as caller ID. Needs numbers:mask.
analysisnone | transcript | fulloptionalHow 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

GEThttps://wayso-audio-backend.fly.dev/v1/calls/:id

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.

POSThttps://wayso-audio-backend.fly.dev/v1/webhooks
{ "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

FieldTypeDescription
Answer fast5s timeoutReturn 200 and do your work afterwards. Anything slower is treated as a failure and retried.
DeduplicateWayso-Audio-Event-IdStable 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.
Retries8 over ~6hExponential 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

POSThttps://wayso-audio-backend.fly.dev/v1/calls

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.

FieldTypeDescription
audiofilerequiredAudio file. Supported: mp3, wav, m4a, ogg, webm. Max 100 MB.
titlestringoptionalHuman-readable label. Defaults to the filename.
prospect_idstringoptionalLink the call to a prospect record (e.g. "prospect:abc123"). See Prospects.
rep_idstringoptionalLink 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

GEThttps://wayso-audio-backend.fly.dev/v1/calls/:id

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

GEThttps://wayso-audio-backend.fly.dev/v1/calls
FieldTypeDescription
statusstringoptionalFilter by status: pending, processing, done, failed
limitnumberoptionalPage size. Default 20, max 100.
offsetnumberoptionalPagination 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

DELETEhttps://wayso-audio-backend.fly.dev/v1/calls/:id

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

GEThttps://wayso-audio-backend.fly.dev/v1/calls/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.

GEThttps://wayso-audio-backend.fly.dev/v1/prospects

List all prospects, each with a calls_count.

POSThttps://wayso-audio-backend.fly.dev/v1/prospects
FieldTypeDescription
namestringrequiredProspect name.
companystringoptionalCompany.
phonestringoptionalPhone number.
emailstringoptionalEmail address.
external_idstringoptionalYour CRM/system ID for this prospect.
notesstringoptionalFreeform 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"
  }
}
GEThttps://wayso-audio-backend.fly.dev/v1/prospects/:id

Returns the prospect plus calls — every call linked to them.

PATCHhttps://wayso-audio-backend.fly.dev/v1/prospects/:id

Update any field (send only the fields you want to change).

DELETEhttps://wayso-audio-backend.fly.dev/v1/prospects/:id

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.

GEThttps://wayso-audio-backend.fly.dev/v1/reps

List all reps, each with a calls_count.

POSThttps://wayso-audio-backend.fly.dev/v1/reps
FieldTypeDescription
namestringrequiredRep name.
emailstringoptionalEmail address.
external_idstringoptionalYour CRM/HR ID for this rep.
activebooleanoptionalWhether 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"
  }
}
GEThttps://wayso-audio-backend.fly.dev/v1/reps/:id

Returns the rep plus calls and a stats object (avg_lead_score, analysed).

PATCHhttps://wayso-audio-backend.fly.dev/v1/reps/:id
DELETEhttps://wayso-audio-backend.fly.dev/v1/reps/:id

API keys

List keys

GEThttps://wayso-audio-backend.fly.dev/v1/api-keys

Session auth required (dashboard use). Returns all keys for your org without exposing the plaintext.

Create a key

POSThttps://wayso-audio-backend.fly.dev/v1/api-keys
FieldTypeDescription
namestringrequiredHuman 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

POSThttps://wayso-audio-backend.fly.dev/v1/api-keys/:id/revoke

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:

FieldTypeDescription
prospectobjectDetected prospect details: name, company, role, phone, email (if mentioned).
lead_scorenumber0–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.
sentimentstring"positive" | "neutral" | "negative" — overall prospect sentiment.
deal_probabilitystring"high" | "medium" | "low" — likelihood of deal closing.
qualificationobjectBANT: { budget: bool, authority: bool, need: bool, timeline: string }.
signalsstring[]Positive buying signals detected in the conversation.
objectionsstring[]Objections raised by the prospect.
rep_performanceobject{ score: 0–100, strengths: string[], improvements: string[] }.
next_stepsstring[]Concrete action items extracted from the call.
summarystring2–3 sentence summary of the call outcome.

Errors

All errors return JSON with an error field.

StatusMeaning
400Bad request — missing or invalid field
401Missing or invalid Bearer token
403Authenticated but not authorized (wrong org, IP blocked)
404Resource not found
409Conflict — e.g. email already registered
413File too large (max 100 MB)
500Internal server error
{ "error": "No active organization" }