WAVV Public API v3
Read your team's call data over REST, and receive call events in real time over signed webhooks. The two share one canonical call shape, so what you poll and what you're pushed are identical. The same reads are also served to AI assistants through the WAVV MCP server.
Getting Started
Overview
The v3 Public API gives you programmatic access to your team's call activity in several ways:
- REST —
GET /calls, plus per-call recording and transcript endpoints, for backfills, reconciliation, and on-demand lookups. - Webhooks —
call.*andmessage.*events pushed to an HTTPS endpoint you register, for real-time work. - MCP — the WAVV MCP server gives Claude, ChatGPT, and other AI assistants the same call reads as tools, plus tools for working call boards. See MCP server.
Both call surfaces serialize a call through the same code path, so a call fetched from GET /calls/{id} and the data object inside a call.ended webhook are field-for-field identical. Build your model once.
All requests and responses are JSON. Timestamps are ISO 8601 in UTC (e.g. 2026-05-21T14:30:00Z). Phone numbers are unformatted 10-digit NANP strings (e.g. 5125551234).
Getting Started
Authentication
Every REST endpoint requires an API key, passed as a Bearer token in the Authorization header:
Authorization: Bearer <your_api_key>
A missing, malformed, or revoked key returns 401 with error code INVALID_API_KEY.
Keys are team-scoped: a key can only ever read calls belonging to the team it was issued for. There is no cross-team or agency-wide call access in this version, and no team id is accepted as a parameter — the key determines the team. Manage keys in the WAVV Integrations panel.
Webhooks are not authenticated with an API key. Each webhook has its own signing secret and is verified by signature — see Verifying signatures.
Getting Started
Rate limits
Requests are counted in one-minute windows, in several buckets at once. A request has to fit in every bucket that applies to it:
includeTranscripts), and, for signed-in users, transcripts. An API key fetching one transcript at a time counts it as an ordinary read. Some MCP tools are heavy reads too.Limits are never by IP address. Assistants such as Claude and ChatGPT call from their own servers, so an IP limit would make customers share one another's budget.
Over a limit, REST answers 429 with code RATE_LIMITED and a Retry-After header giving the whole seconds until that bucket's window resets:
HTTP/1.1 429 Too Many Requests Retry-After: 23
Wait at least that long. A rejected request still counts against the window it was made in, so retrying in a tight loop only keeps you limited.
The MCP server and the REST API share the same counters — a tool call and a REST request cost the same, so switching between the two does not double your budget. Requests rejected before they reach an endpoint — a 401, a 403, or a 400 for a malformed parameter — are not counted. A 400 an endpoint raises after reading the request is.
Getting Started
Pagination and filtering
GET /calls returns calls newest-first and pages with a keyset cursor.
nextCursor. Pass it back as the cursor query parameter to get the next page. nextCursor is null on the last page. The value is the startedAt of the last returned call — treat it as opaque; the format may change.limit defaults to 50 and is capped at 200. A value above the cap is reduced to 200 rather than rejected. A value that is zero, negative, or not a number falls back to the default of 50. With includeTranscripts=true the cap is 25, since each call then carries its full transcript.startedAfter and startedBefore bound startedAt inclusively, and each may be used on its own. Send both as ISO 8601 — a value that can't be parsed as a date is treated as absent, so validate before sending to be sure you get the window you intend.id if your integration requires exactness across page boundaries.Webhooks
Verifying signatures
Every delivery carries an X-WAVV-Signature header:
X-WAVV-Signature: t=1779720600,v1=6f2a…c91b
t— Unix timestamp in seconds, stamped fresh at send time (so a retry carries a newt).v1— hexHMAC-SHA256of the timestamp, a literal dot, and the raw body concatenated together, keyed with the webhook's signing secret.
To verify, in order:
- Read the raw request body as bytes, before any JSON parsing. Re-serializing a parsed body changes the bytes and the signatures will not match.
- Recompute the HMAC over
t + "." + rawBodywith your signing secret. - Compare against
v1in constant time (crypto.timingSafeEqual, not===). - Reject the delivery if
|now - t|exceeds your tolerance. 300 seconds is a common choice; this check is what prevents a captured delivery from being replayed later.
The signing secret is prefixed whsec_ and is available in the Integrations panel. Because verification requires the secret itself, it remains retrievable there — handle it with the same care as a password. Deleting a webhook also deletes its signing secret.
const crypto = require('node:crypto'); function verify(rawBody, header, secret, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('='))); const t = Number(parts.t); if (!Number.isFinite(t)) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1 ?? '', 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); }
Webhooks
Delivery and retries
Register endpoints in the WAVV Integrations panel: give the webhook a name, an HTTPS URL, and the set of events it should receive. Each webhook can subscribe to any combination of the events, and a team can have several.
POST with Content-Type: application/json and the X-WAVV-Signature header. Your endpoint has 10 seconds to respond before the attempt times out.2xx acknowledges the delivery. Any other status, including a redirect, is treated as a failure and retried. Acknowledge first and process the payload afterwards.(event, data.id) and design each handler to be safely replayable.http or https URL on a publicly routable host. localhost and addresses in loopback, RFC-1918 private, link-local, and CGNAT ranges are not accepted. This is validated at registration and confirmed again before each delivery.Calls
List calls
Returns the team's calls, newest first. Every parameter is optional.
transcript is omitted from this response to keep pages small. Fetch it per-call from GET /calls/{id}/transcript, or, to read many transcripts at once (scoring calls against a script, comparing openers), set includeTranscripts=true: each call then carries a transcript field, pages hold at most 25 calls because a transcript can run to tens of kilobytes, and the request counts as a heavy read.
Filters combine with AND.
The direction filter selects by call type: outbound returns the calls your team dialed, inbound returns calls received on a team number, including forwarded ones, and all — the default — returns both, merged newest first. Internal call types such as whispers and transfers are not returned by any direction, although such a call fetched by id reports direction: outbound.
Calls made by an agent who has since left the team are still returned by every filter: userId takes any agent who appears on a call, and the contact filters (contactId, contactName) search your team's CRM locations, not just its current members.
With an API key, a repeated limit uses its first value, and a repeated campaignId, startedAfter, startedBefore or cursor is ignored. With a Sign in with WAVV token, any repeated parameter is rejected with INVALID_REQUEST.
| Parameter | Type | Description |
|---|---|---|
| direction | stringopt | Which calls to return: outbound (calls the team dialed), inbound (calls received, including forwarded ones), or all. Defaults to all. A repeated or unrecognised value is rejected with INVALID_REQUEST rather than falling back to the default. One of: inbound, outbound, all. |
| campaignId | string (uuid)opt | Restrict to a single dialing campaign. Must be a UUID — anything else is rejected with INVALID_REQUEST rather than simply matching no calls. With an API key, an empty value is ignored. |
| userId | stringopt | Only this agent's calls: the agent's id in your CRM, the same value as a call's userId. An id that matches nobody on the team returns an empty page, not an error, so a typo looks exactly like an agent with no calls. |
| phone | stringopt | Only calls with the contact on this number: matched against phone on outbound calls, and against callerId on inbound and forwarded ones, since there the contact is the one calling. Your own WAVV numbers are never matched, so this finds a contact's calls in both directions. Any format is accepted: non-digits are stripped and a leading country code 1 is dropped, so +1 (512) 555-1234 matches 5125551234. At least 7 characters. |
| contactId | stringopt | Only calls with this contact: the contact's id in your CRM, the same value as a call's contactId. Inbound calls only carry a contactId when the caller matched a known contact. |
| contactName | stringopt | Only calls whose contactName contains this text, ignoring case. At least 3 characters. Without startedAfter, only the 90 days before startedBefore (or before now) are searched; send startedAfter to search further back. |
| startedAfter | string (date-time)opt | Only calls with startedAt at or after this instant. Unparseable values are ignored. |
| startedBefore | string (date-time)opt | Only calls with startedAt at or before this instant. Unparseable values are ignored. |
| cursor | stringopt | The nextCursor from a previous response. Omit for the first page. An unparseable value is ignored, which returns the first page again. |
| limit | integeropt | Page size, 1–200. Defaults to 50. A larger value is reduced to 200; a value that is zero, negative, or not a number falls back to 50. With includeTranscripts=true, a value above 25 (including the default) is reduced to 25. |
| includeTranscripts | booleanopt | true (or 1) to give each call its full transcript — null for a call that has none, or none yet. Pages then hold at most 25 calls, and the request counts as a heavy read. Defaults to false. Any value other than true, false, 1, or 0 is rejected with INVALID_REQUEST. |
direction was not one of inbound, outbound, all; campaignId was not a UUID; phone was shorter than 7 characters or contactName shorter than 3; userId or contactId was empty; includeTranscripts was not true, false, 1, or 0; or a parameter was sent more than once — INVALID_REQUEST.INVALID_API_KEY.FORBIDDEN.RATE_LIMITED, with Retry-After.SERVER_ERROR.QUERY_TIMEOUT. Narrow the date range or filters.{ "data": [ { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "5d1e8a72-9c34-4f60-8b2a-7e0d3c5f9b18", "direction": "outbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "crm-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:28:12Z", "answeredAt": "2026-05-21T14:28:19Z", "endedAt": "2026-05-21T14:30:00Z", "seconds": 101, "outcome": "HUNG_UP", "disposition": "Interested", "human": true, "note": "Sending pricing.", "summary": "Prospect asked for pricing on the multi-line plan.", "recorded": true } ], "nextCursor": "2026-05-21T14:28:12Z" }
Calls
Get a call
Returns one call. transcript is excluded, as it is from the list endpoint unless the list is asked for includeTranscripts.
A call that exists but belongs to another team returns 404, not 403 — the API does not confirm the existence of calls outside your team.
| Parameter | Type | Description |
|---|---|---|
| id | string (uuid)req | The call's unique identifier. |
id was not a UUID — INVALID_REQUEST.INVALID_API_KEY.FORBIDDEN.NOT_FOUND.RATE_LIMITED, with Retry-After.SERVER_ERROR.{ "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "direction": "inbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "crm-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:30:00Z", "answeredAt": "2026-05-21T14:30:00Z", "endedAt": "2026-05-21T14:30:00Z", "seconds": 101, "outcome": "BUSY", "disposition": "Interested", "human": true, "note": "string", "summary": "string", "recorded": true }
Calls
Get a recording URL
Returns a freshly signed URL for the call's audio recording.
The URL is generated per request and is valid for the window given in expiresAt, currently 72 hours. Store the call id rather than the URL, and request a new one whenever you need access.
A 404 covers both a call that has no recording and a call id that isn't available to your credential. Read recorded on the call object beforehand when you need to tell the two apart.
| Parameter | Type | Description |
|---|---|---|
| id | string (uuid)req | The call's unique identifier. |
id was not a UUID — INVALID_REQUEST.INVALID_API_KEY.FORBIDDEN.NOT_FOUND.RATE_LIMITED, with Retry-After.SERVER_ERROR.{ "url": "https://example.com", "expiresAt": "2026-05-21T14:30:00Z" }
Calls
Get a transcript
Returns the call's transcript and AI-generated summary, or just one of them.
Both fields are populated asynchronously once the call ends, so a 200 carrying transcript: null indicates the transcript is not yet available for that call. Waiting for the call.recorded event before fetching is more efficient than polling.
summary also appears on the call object; it is repeated here so one request returns both halves of the post-call content. Transcripts can be long: when the summary may be enough, ask for part=summary first. With part=summary or part=transcript the response carries only that key — the other is absent, not null.
A heavy read for signed-in users; an ordinary read for API keys.
| Parameter | Type | Description |
|---|---|---|
| id | string (uuid)req | The call's unique identifier. |
| Parameter | Type | Description |
|---|---|---|
| part | stringopt | Which half to return: summary, transcript, or both. Defaults to both. One of: summary, transcript, both. |
id was not a UUID, or part was not one of summary, transcript, both — INVALID_REQUEST.INVALID_API_KEY.FORBIDDEN.NOT_FOUND.RATE_LIMITED, with Retry-After.SERVER_ERROR.{ "transcript": "Agent: Hi Dana, this is…", "summary": "Prospect asked for pricing on the multi-line plan." }
Five events cover the life of a call. Every payload uses the same envelope — event, sentAt, and a data object holding the Call — so one handler can serve all five and branch on event.
Call events
Outbound call started
Fires the moment an outbound call is created — when the dialer places it, before the far end has answered or rejected.
Because the call has only just begun, endedAt, seconds, answeredAt, disposition, human, note, and summary are null, and outcome is UNKNOWN. Use this event to open a record that call.ended will complete; call results are not available until then.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: call.started, call.incoming, call.answered, call.ended, call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. It is set once when the event is dispatched, so retries of the same delivery carry the original value. Use the t parameter in X-WAVV-Signature for freshness and replay checks. |
| data | Callreq | The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it. |
{ "event": "call.started", "sentAt": "2026-05-21T14:28:12Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "5d1e8a72-9c34-4f60-8b2a-7e0d3c5f9b18", "direction": "outbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "crm-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:28:12Z", "answeredAt": null, "endedAt": null, "seconds": null, "outcome": "UNKNOWN", "disposition": null, "human": null, "note": null, "summary": null, "recorded": false } }
Call events
Inbound call received
Fires when an inbound call arrives on one of the team's numbers, at the point the call record is created — before it is answered, routed, or forwarded.
The counterpart to call.started for the inbound direction, with the same fields still unpopulated. phone and callerId follow the call rather than the account, so here phone is your WAVV number that was dialed and callerId is the caller's number. campaignId and contactId are null unless the caller matches a known CRM contact.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: call.started, call.incoming, call.answered, call.ended, call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. It is set once when the event is dispatched, so retries of the same delivery carry the original value. Use the t parameter in X-WAVV-Signature for freshness and replay checks. |
| data | Callreq | The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it. |
{ "event": "call.incoming", "sentAt": "2026-05-21T16:02:44Z", "data": { "id": "b7e4d290-13af-4c65-9a08-fd2e6b510c77", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": null, "direction": "inbound", "phone": "5125559876", "callerId": "5125551234", "contactId": null, "contactName": null, "startedAt": "2026-05-21T16:02:44Z", "answeredAt": null, "endedAt": null, "seconds": null, "outcome": "UNKNOWN", "disposition": null, "human": null, "note": null, "summary": null, "recorded": false } }
Call events
Call answered
Fires when a call connects to the agent — an outbound call the contact picked up, or an inbound call the agent took. It fires once per call: putting the call on hold or transferring it does not fire it again.
Calls that never connect never fire it. Neither does a voicemail that answering-machine detection screens out before it reaches the agent. A voicemail that does reach the agent fires it like any other answer, so check human on call.ended before treating the call as a conversation.
answeredAt is populated with the moment the call connected. As on call.started, the call is still live, so endedAt, seconds, disposition, human, note, and summary are null, and outcome is UNKNOWN.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: call.started, call.incoming, call.answered, call.ended, call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. It is set once when the event is dispatched, so retries of the same delivery carry the original value. Use the t parameter in X-WAVV-Signature for freshness and replay checks. |
| data | Callreq | The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it. |
{ "event": "call.answered", "sentAt": "2026-05-21T14:28:21Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "5d1e8a72-9c34-4f60-8b2a-7e0d3c5f9b18", "direction": "outbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "ghl-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:28:12Z", "answeredAt": "2026-05-21T14:28:19Z", "endedAt": null, "seconds": null, "outcome": "UNKNOWN", "disposition": null, "human": null, "note": null, "summary": null, "recorded": false } }
Call events
Call ended and dispositioned
Fires when the call is over and its outcome has been recorded. This is the complete call: endedAt, seconds, answeredAt, outcome, human, and any disposition or note the agent entered are all populated.
For most integrations this is the only event you need. It fires for both directions.
Two fields may still be null here and arrive later: summary (AI-generated) and any recording — see call.recorded.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: call.started, call.incoming, call.answered, call.ended, call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. It is set once when the event is dispatched, so retries of the same delivery carry the original value. Use the t parameter in X-WAVV-Signature for freshness and replay checks. |
| data | Callreq | The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it. |
{ "event": "call.ended", "sentAt": "2026-05-21T14:30:01Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "5d1e8a72-9c34-4f60-8b2a-7e0d3c5f9b18", "direction": "outbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "crm-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:28:12Z", "answeredAt": "2026-05-21T14:28:19Z", "endedAt": "2026-05-21T14:30:00Z", "seconds": 101, "outcome": "HUNG_UP", "disposition": "Interested", "human": true, "note": "Sending pricing.", "summary": null, "recorded": true } }
Call events
Recording available
Fires when a call's audio recording has finished processing and is available.
This is the only event whose data carries an extra field: recordingUrl, a signed URL for the audio. The URL is generated at send time and is time-limited, so download the audio when you receive it, or request a fresh URL from GET /calls/{id}/recording later. Store the call id rather than the URL.
This event typically follows call.ended within seconds. As with all events, order is not guaranteed — see Delivery and retries.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Always call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. |
| data | CallWithRecordingreq | A Call plus the signed recording URL. Used only as the data of a call.recorded event. |
{ "event": "call.recorded", "sentAt": "2026-05-21T14:30:14Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "groupId": "west-region", "subgroupId": "team-4", "campaignId": "5d1e8a72-9c34-4f60-8b2a-7e0d3c5f9b18", "direction": "outbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "crm-99213", "contactName": "Dana Reyes", "startedAt": "2026-05-21T14:28:12Z", "answeredAt": "2026-05-21T14:28:19Z", "endedAt": "2026-05-21T14:30:00Z", "seconds": 101, "outcome": "HUNG_UP", "disposition": "Interested", "human": true, "note": "Sending pricing.", "summary": "Prospect asked for pricing on the multi-line plan.", "recorded": true, "recordingUrl": "https://recordings.wavv.com/3c9a17b4…?X-Amz-Expires=259200&X-Amz-Signature=…" } }
Three events follow a text message: sent, received, and failed. The envelope matches the call events — event, sentAt, and a data object — with a Message in data, so the same handler and the same signature check serve both.
Message events
Text message sent
Fires once when the carrier accepts an outbound text — sent, not yet delivered. A message the carrier later reports as undelivered follows with message.failed, so a message can fire both.
Scheduled messages fire when they actually go out, not when they are scheduled.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: message.sent, message.received, message.failed. |
| sentAt | string (date-time)req | When this payload was rendered. Retries of the same delivery carry the original value. |
| data | Messagereq | A text message, as delivered in every message.* webhook. Like the Call, its ids are yours: userId is the id you passed to POST /tokens, and contactId is your contact id. |
{ "event": "message.sent", "sentAt": "2026-05-21T14:31:03Z", "data": { "id": "0b6f2d1e-8a4c-4e3b-9f71-2c5d8e1a4b90", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "conversationId": "6a2e9c47-1d3b-4f58-a0e6-93b7c5d21f84", "direction": "outbound", "userNumber": "5125559876", "contactNumber": "5125551234", "contactId": "ghl-99213", "body": "Thanks for your time today — here is the pricing sheet.", "attachments": [], "segments": 1, "status": "sent", "failedReason": null, "createdAt": "2026-05-21T14:31:02Z", "sentAt": "2026-05-21T14:31:03Z", "failedAt": null } }
Message events
Text message received
Fires when a contact texts one of your users' WAVV numbers.
userId is the user whose conversation it landed in. On a number the whole team shares there is no single owner, so userId is null; use teamId and conversationId to place the message.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: message.sent, message.received, message.failed. |
| sentAt | string (date-time)req | When this payload was rendered. Retries of the same delivery carry the original value. |
| data | Messagereq | A text message, as delivered in every message.* webhook. Like the Call, its ids are yours: userId is the id you passed to POST /tokens, and contactId is your contact id. |
{ "event": "message.received", "sentAt": "2026-05-21T14:40:17Z", "data": { "id": "4d1c7e93-2b6a-4f0e-8c35-a9e2f7b16d08", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "conversationId": "6a2e9c47-1d3b-4f58-a0e6-93b7c5d21f84", "direction": "inbound", "userNumber": "5125559876", "contactNumber": "5125551234", "contactId": "ghl-99213", "body": "Got it, thanks. Can we talk Thursday?", "attachments": [], "segments": 1, "status": "received", "failedReason": null, "createdAt": "2026-05-21T14:40:16Z", "sentAt": "2026-05-21T14:40:16Z", "failedAt": null } }
Message events
Text message failed
Fires once when an outbound text fails — rejected before it left WAVV (for example, the number is on a do-not-contact list), refused by the carrier, or reported undelivered after it was sent. failedReason says why, in words you can show the user.
A message the carrier accepted and later reported undelivered has already fired message.sent, so treat message.failed as overriding it rather than as a message that was never sent.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: message.sent, message.received, message.failed. |
| sentAt | string (date-time)req | When this payload was rendered. Retries of the same delivery carry the original value. |
| data | Messagereq | A text message, as delivered in every message.* webhook. Like the Call, its ids are yours: userId is the id you passed to POST /tokens, and contactId is your contact id. |
{ "event": "message.failed", "sentAt": "2026-05-21T14:31:09Z", "data": { "id": "0b6f2d1e-8a4c-4e3b-9f71-2c5d8e1a4b90", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "userId": "crm-user-4471", "conversationId": "6a2e9c47-1d3b-4f58-a0e6-93b7c5d21f84", "direction": "outbound", "userNumber": "5125559876", "contactNumber": "5125551234", "contactId": "ghl-99213", "body": "Thanks for your time today — here is the pricing sheet.", "attachments": [], "segments": 1, "status": "failed", "failedReason": "The carrier could not deliver this message to the recipient.", "createdAt": "2026-05-21T14:31:02Z", "sentAt": "2026-05-21T14:31:03Z", "failedAt": "2026-05-21T14:31:08Z" } }
MCP
MCP server
WAVV runs a Model Context Protocol server, so an AI assistant such as Claude or ChatGPT can work with your team's calls and call boards directly: "how many conversations did we have yesterday", "why did this lead leave the Follow up stage", "get a calling session ready for everyone in Follow up".
https://app.wavv.com/api/v3/mcp
The server speaks Streamable HTTP and is stateless: every request is a POST that stands on its own, with no session to keep. GET and DELETE answer 405.
Connecting
Claude. Open Customize → Connectors, click +, choose Add custom connector, and paste the URL above. Click Connect, sign in with WAVV, pick the WAVV account to connect if you have more than one, and choose Allow.
ChatGPT. Go to chatgpt.com/plugins, click +, choose Add custom MCP server, paste the URL above, choose OAuth, and sign in with WAVV when asked.
Cursor or VS Code. Add the URL above as an HTTP server — in Cursor, an entry in mcp.json with "url"; in VS Code, MCP: Add Server → HTTP — and sign in with WAVV when the editor asks.
With an API key. Clients that let you set headers, such as Claude Code, can connect with an API key instead of signing in:
claude mcp add --transport http wavv https://app.wavv.com/api/v3/mcp --header "Authorization: Bearer <key>"
What the assistant can do
That depends on how it connected:
- Signed in with WAVV — every tool below. The assistant acts as the person who signed in: it sees only the boards and contacts they see in WAVV, and can change only what they could change there. The reports are the exception: like the Reports page, they cover the whole team. Only owners, admins, and managers can connect this way.
- API key — the call tools,
get_me, andget_call_report. A key belongs to the team rather than a person, so the call board and calling tools are not offered.
Who is connected. get_me returns the team and, for someone signed in, the person: their role, their id in your CRM (pass it as userId for "my calls"), their time zone, and the WAVV tabs they have open. Assistants call it first.
Calls. Each mirrors a REST endpoint and takes the same parameters:
list_calls—GET /callsget_call—GET /calls/{id}get_call_transcript—GET /calls/{id}/transcriptget_call_recording_url—GET /calls/{id}/recording
Call report. get_call_report returns one section of the call report on WAVV's Reports page, with the same numbers the page shows, for the team or one agent and optionally one call board: activity per agent and per day, week, or month; totals per agent; or a heatmap of dispositions by weekday and hour. Assistants use it for "how many calls did we make yesterday" rather than paging through calls. Like the page, it goes back to the 1st of the month a year ago; an earlier start is moved up to that day. The report is team-wide: it covers every agent on the team, whoever is asking.
Call boards (signed in only):
list_boards— the boards the person can open.get_board— a board's setup: stages, automations written out as rules, calling settings, where its contacts come from, and its members.list_stage_contacts— the contacts in one stage, in dialing order, with attempts, time in stage, and who they're assigned to.find_board_contacts— finds contacts on a board.get_contact_trail— a contact's history on a board: stage moves, calls, and the rules that moved them.get_automation_run— what one automation run did.get_board_calls— a board's calls over a window of time, and whether each contact moved stage or an automation ran afterwards.get_board_activity— everything that happened on a board over a window of time.get_board_import_historyandget_board_automation_history— contacts added to a board, and changes to its rules.get_board_report— one section of the Call Boards report, with the same numbers as WAVV's Reports page. Like the page, it is team-wide: it covers every active board on the team, including boards the person signed in can't open.
The board tools name people the way calls do: userId is the person's user id in your CRM (null if they have none), and wavvUserId is WAVV's own id for them. A board member from get_board can be passed as either to filter get_board_report or to assign contacts.
Changing a board (signed in only):
preview_import,import_to_board,get_import_status— add contacts from your CRM to a board.preview_bulk_action,run_bulk_action,get_bulk_action_status— act on many contacts at once, such as moving, assigning, snoozing, or removing them, or moving them to another board.
Both always go in two steps. The preview says exactly what will happen, to how many contacts, and changes nothing. The change runs only with that preview's confirm token, which works once and expires after 10 minutes, so the assistant has to show the preview and get a yes first.
Calling (signed in only):
prepare_campaign— gets a calling session ready, on a call board or for a list of CRM contacts. It appears in the person's open WAVV tab, and they press Start.
The assistant never dials. import_to_board, run_bulk_action, and prepare_campaign are marked as changing something, so clients ask before running them.
Feedback (signed in only):
submit_feedback— sends the WAVV team a feature request, bug report, or other feedback. When someone asks for something the tools can't do, the assistant offers to send it, and sends it only if they say yes.
Prompts
Ready-made requests a person picks from their assistant's prompt menu (/ in Claude):
review_yesterdays_calls— totals, standout calls, and follow-ups from yesterday.score_calls_against_script— grades recent conversations against a script you give it. Arguments:script, anddaysto look back (default 7).who_should_i_call_next— suggests which call board leads to work next, and why. Signed in only.clean_up_a_board— finds contacts on a board that look stuck or over-called and suggests what to do with each group, previewing any change before it runs. Argument:board, the board's name. Signed in only.
Responses, errors, and limits
A tool returns JSON, as text; the call tools return the same JSON as their endpoints. A failure comes back as a tool error carrying the same message the REST error field would. Tool calls count against the same rate limits as REST requests. The call report, the board report, the board calls and activity reads, both previews, and a calling session for a list of CRM contacts are heavy reads.
OAuth discovery
You only need this if you are writing an MCP client. A request without a valid credential gets 401 with a challenge pointing at the server's protected-resource metadata (RFC 9728), which names WAVV's authorization server:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://app.wavv.com/api/v3/mcp/.well-known/oauth-protected-resource"
The metadata is also served at the RFC 9728 well-known path, https://app.wavv.com/.well-known/oauth-protected-resource/api/v3/mcp. Tokens are issued for the resource https://app.wavv.com/api/v3/mcp with the mcp scope.
Reference
Object reference
The shape of every object the API returns or accepts. A field marked req is always present in a response (or required in a request body). Types ending in | null may come back as null.
The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it.
| Field | Type | Description |
|---|---|---|
| id | string (uuid)req | The call's unique identifier. Stable; safe to store and use as your dedupe key. |
| teamId | string (uuid)req | The team the call belongs to. Always the team your credential is scoped to. |
| userId | stringreq | The agent who made or received the call, in your identifier space — the same id you supplied when provisioning the user, not a WAVV-internal one. Never null. |
| groupId | string | nullreq | Your grouping for the agent, if you set one when provisioning them. null when the account does not use groups. |
| subgroupId | string | nullreq | Your second-level grouping, if you set one. null when unused. |
| campaignId | string (uuid) | nullreq | The dialing campaign this call was placed under. null for inbound and ad-hoc calls. |
| direction | stringreq | inbound covers calls received on a team number, including forwarded ones. Everything else — dialer-placed calls, and internal types such as whispers and transfers — reports as outbound. The direction filter on GET /calls is narrower: its outbound returns only dialer-placed calls, so the internal types are never listed. One of: inbound, outbound. |
| phone | stringreq | The number that was called. On outbound that is the contact you dialed; on inbound it is your own WAVV number that the caller reached. Unformatted 10 digits. |
| callerId | stringreq | The number the call came from. On outbound that is the WAVV caller ID you presented; on inbound it is the external caller's number. Unformatted 10 digits. Because phone and callerId follow the call rather than the account, read direction first to determine which of the two identifies your contact. |
| contactId | string | nullreq | The contact's id in the connected CRM, when the call was placed against a known contact. null for manual dials and unmatched inbound calls. |
| contactName | string | nullreq | The contact's display name at the time of the call. null when unknown. |
| startedAt | string (date-time)req | When the call was created — dialed, or received. Also the sort key and pagination cursor. |
| answeredAt | string (date-time) | nullreq | When the call connected, derived as endedAt - seconds. null until both of those are known, and for calls that were never answered. On call.answered it is instead the moment the call connected, since the call has not ended yet; the derived value on later events can differ from it by a second or two. |
| endedAt | string (date-time) | nullreq | When the call finished. null while the call is still in progress. |
| seconds | integer | nullreq | Talk time in whole seconds. null while the call is in progress or if it never connected. |
| outcome | stringreq | How the call resolved, as determined by WAVV. Always present: a call that has not resolved yet reports UNKNOWN rather than null. Treat this list as open — new members may be added. One of: BUSY, DISCONNECTED, NO_ANSWER, HUNG_UP, USER_HUNG_UP, UNKNOWN, VOICEMAIL, CALLBACK, NO_VOICEMAIL, NO_CALLBACK, TRANSFERRED, VOICEMAIL_RETRY, REJECTED. |
| disposition | string | nullreq | The free-text disposition the agent selected, drawn from your team's own configured list. null if none was set. Values are team-defined, not a WAVV enum. |
| human | boolean | nullreq | Whether a human answered, according to WAVV's answering-machine detection. false means a machine was detected; null means no determination was made, which is a distinct result from false. |
| note | string | nullreq | Free-text note the agent attached to the call. null if none. |
| summary | string | nullreq | AI-generated call summary. Produced asynchronously after the call ends, so it is typically null on call.ended and populated shortly after. |
| recorded | booleanreq | Whether audio was recorded. When true, fetch the audio from GET /calls/{id}/recording — the URL is never embedded in this object. |
A Call plus the signed recording URL. Used only as the data of a call.recorded event.
| Field | Type | Description |
|---|---|---|
| recordingUrl | string (uri)req | Signed, time-limited URL to the call audio. Generated at send time and valid for 72 hours. Download promptly; do not store the URL. |
A Call plus its transcript. Returned only by list calls with includeTranscripts=true; never by GET /calls/{id} or in a webhook payload, which share the plain Call shape.
| Field | Type | Description |
|---|---|---|
| transcript | string | nullreq | The full call transcript, as GET /calls/{id}/transcript returns it. null when the call was not transcribed, or the transcript is not ready yet. Can run to tens of kilobytes, which is why pages that carry it hold at most 25 calls. |
One page of calls.
| Field | Type | Description |
|---|---|---|
| data | Call[]req | The page of calls, newest first. With includeTranscripts=true, each is a CallWithTranscript. |
| nextCursor | string | nullreq | Pass back as cursor for the next page. null on the last page. |
A freshly signed link to a call's audio.
| Field | Type | Description |
|---|---|---|
| url | string (uri)req | Signed URL to the audio file. |
| expiresAt | string (date-time)req | When the signature stops working — 72 hours from the request. |
Post-call text content. Both fields are populated asynchronously. Each is present only when part asks for it — both by default — so neither is marked required.
| Field | Type | Description |
|---|---|---|
| transcript | string | nullopt | Full call transcript. null if not transcribed, or not ready yet. Absent when part=summary. |
| summary | string | nullopt | AI-generated summary. Same value as summary on the call object; null until it has been generated. Absent when part=transcript. |
The envelope every webhook delivery uses.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: call.started, call.incoming, call.answered, call.ended, call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. It is set once when the event is dispatched, so retries of the same delivery carry the original value. Use the t parameter in X-WAVV-Signature for freshness and replay checks. |
| data | Callreq | The canonical call shape. Identical in REST responses and webhook payloads. transcript is not part of it; fetch it from GET /calls/{id}/transcript, or list calls with includeTranscripts=true, which returns CallWithTranscript. Webhooks never carry it. |
Identical to CallEvent, except data also carries recordingUrl.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Always call.recorded. |
| sentAt | string (date-time)req | When this payload was rendered. |
| data | CallWithRecordingreq | A Call plus the signed recording URL. Used only as the data of a call.recorded event. |
A text message, as delivered in every message.* webhook. Like the Call, its ids are yours: userId is the id you passed to POST /tokens, and contactId is your contact id.
| Field | Type | Description |
|---|---|---|
| id | string (uuid)req | WAVV's id for the message. Stable across the events for one message, so deduplicate on (event, id). |
| teamId | string (uuid) | nullreq | The team the conversation belongs to. |
| userId | string | nullreq | Your id for the user — the sender of an outbound message, or the owner of the conversation an inbound one arrived in. null for a message received on a number the whole team shares, since no single user owns it. |
| conversationId | string (uuid) | nullreq | The conversation thread the message belongs to. Every message in one thread shares it. |
| direction | stringreq | outbound when your user sent it, inbound when the contact did. One of: outbound, inbound. |
| userNumber | string | nullreq | The WAVV number on your user's side of the conversation, whichever direction the message went. |
| contactNumber | string | nullreq | The contact's number, whichever direction the message went. |
| contactId | string | nullreq | Your id for the contact, when the conversation is linked to one of your contacts. null for a number WAVV has not matched to a contact of yours. |
| body | stringreq | The message text. Empty string for a message that carries only attachments. |
| attachments | string (uri)[]req | URLs of any images or files sent with the message. Empty when there are none. |
| segments | integerreq | How many SMS segments the carrier billed the body as. |
| status | stringreq | Matches the event — sent on message.sent, and so on. One of: sent, received, failed. |
| failedReason | string | nullreq | Why the carrier rejected the message, in words meant for a person. null unless status is failed. |
| createdAt | string (date-time)req | When WAVV created the message. |
| sentAt | string (date-time) | nullreq | For an outbound message, when the carrier accepted it; for an inbound one, when WAVV received it. null for an outbound message that failed before the carrier took it. |
| failedAt | string (date-time) | nullreq | When the message failed. null unless status is failed. |
The envelope every message.* delivery uses — the same as CallEvent, with a Message in data.
| Field | Type | Description |
|---|---|---|
| event | stringreq | Which event fired. Branch your handler on this. One of: message.sent, message.received, message.failed. |
| sentAt | string (date-time)req | When this payload was rendered. Retries of the same delivery carry the original value. |
| data | Messagereq | A text message, as delivered in every message.* webhook. Like the Call, its ids are yours: userId is the id you passed to POST /tokens, and contactId is your contact id. |
Every non-2xx REST response uses this shape.
| Field | Type | Description |
|---|---|---|
| error | stringreq | Human-readable message intended for logs and diagnostics. Branch on code rather than this field, as the wording may change. |
| code | stringreq | Machine-readable code — see Error codes. |
Reference
Error codes
Every error response carries a machine-readable code. The known codes:
| Code | Meaning |
|---|---|
| INVALID_API_KEY | Missing, malformed, or revoked API key, or a Sign in with WAVV access token that is invalid or expired. Always 401. |
| FORBIDDEN | The signed-in user may not use the API — their role is not owner, admin, or manager, or they are not on an active team. 403. |
| NOT_FOUND | The call does not exist, or belongs to another team. Also returned when a call exists but has no recording. |
| INVALID_REQUEST | The request was malformed, or a parameter failed validation. 400. |
| RATE_LIMITED | Too many requests. The response carries a Retry-After header giving the seconds to wait — see Rate limits. 429. |
| QUERY_TIMEOUT | The request took too long to answer and was stopped: 30 seconds for a call list, 60 for a call board report. Retrying the same request will likely time out again; narrow the date range or filters. 503. |
| SERVER_ERROR | Something failed on our side. Safe to retry. Usually 500; 503 when WAVV briefly can't check a credential. |
{ "error": "Call not found", "code": "NOT_FOUND" }