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.
Getting Started
Overview
The v3 Public API gives you programmatic access to your team's call activity in two ways:
- REST —
GET /calls, plus per-call recording and transcript endpoints, for backfills, reconciliation, and on-demand lookups. - Webhooks — four
call.*events pushed to an HTTPS endpoint you register, for real-time work.
Both 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
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. Values outside that range, and values that aren't positive integers, resolve to the default.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 four 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. All parameters are optional; with none supplied you get the most recent page of every call on the team.
transcript is omitted from this response to keep pages small. Fetch it per-call from GET /calls/{id}/transcript.
Filters combine with AND.
The direction filter selects by call type: inbound returns received and forwarded calls, and outbound returns dialer-placed calls. Specialized call types such as whispers and transfers carry direction: "outbound" on the call object but fall outside the direction=outbound filter. To retrieve every call regardless of type, omit the filter and read the direction field from each result.
| Parameter | Type | Description |
|---|---|---|
| direction | stringopt | Restrict to one call direction. One of: inbound, outbound. |
| campaignId | string (uuid)opt | Restrict to a single dialing campaign. |
| 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. |
| limit | integeropt | Page size, 1–200. Defaults to 50. Invalid values fall back to the default. |
INVALID_API_KEY.SERVER_ERROR.{ "data": [ { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "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": "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. As with the list endpoint, transcript is excluded.
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. |
INVALID_API_KEY.NOT_FOUND.SERVER_ERROR.{ "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "campaignId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "direction": "inbound", "phone": "5125551234", "callerId": "5125559876", "contactId": "ghl-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 key. 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. |
INVALID_API_KEY.NOT_FOUND.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.
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.
| Parameter | Type | Description |
|---|---|---|
| id | string (uuid)req | The call's unique identifier. |
INVALID_API_KEY.NOT_FOUND.SERVER_ERROR.{ "transcript": "Agent: Hi Dana, this is…", "summary": "string" }
Four 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 four 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.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 never included; fetch it from GET /calls/{id}/transcript. |
{ "event": "call.started", "sentAt": "2026-05-21T14:28:12Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "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": 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.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 never included; fetch it from GET /calls/{id}/transcript. |
{ "event": "call.incoming", "sentAt": "2026-05-21T16:02:44Z", "data": { "id": "b7e4d290-13af-4c65-9a08-fd2e6b510c77", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "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 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.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 never included; fetch it from GET /calls/{id}/transcript. |
{ "event": "call.ended", "sentAt": "2026-05-21T14:30:01Z", "data": { "id": "3c9a17b4-2f5e-4d81-b0a7-6e2c9d4f1a03", "teamId": "8f3b2c10-4e7a-4b1c-9d2e-1a2b3c4d5e6f", "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": "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", "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": "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=…" } }
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 never included; fetch it from GET /calls/{id}/transcript.
| 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 API key is scoped to. |
| 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. 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 | string | nullreq | 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. |
| 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 | string | nullreq | How the call resolved, as determined by WAVV. UNKNOWN until the call ends. 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. |
| 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. |
One page of calls.
| Field | Type | Description |
|---|---|---|
| data | Call[]req | The page of calls, newest first. |
| 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.
| Field | Type | Description |
|---|---|---|
| transcript | string | nullreq | Full call transcript. null if not transcribed, or not ready yet. |
| summary | string | nullreq | AI-generated summary. Same value as summary on the call object. |
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.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 never included; fetch it from GET /calls/{id}/transcript. |
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. |
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. |
| KEY_SCOPE_INSUFFICIENT | The key cannot access the requested resource. |
| 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. |
| RATE_LIMITED | Too many requests. Back off and retry. |
| SERVER_ERROR | Something failed on our side. Safe to retry. |
{ "error": "Call not found", "code": "NOT_FOUND" }