API reference
Calls
List and filter calls, watch the calls in progress, export the log as CSV, read one call with its full waterfall, stream its recording, and post conversions, tags and adjustments.
With these endpoints a program can do everything the call log does: find any call, explain why it went where it went, and correct its books. A call is one inbound call to one of your tracking numbers, or one call a publisher won at auction and sent you — routed or not. Only tracking calls appear here; any other call's id answers 404.
The call object
A row of the list. The campaign, publisher, buyer and target objects are snapshots frozen on the call, so a buyer renamed or deleted since still reads as it was; id is null when the record has since been deleted, and the whole object is null when the call never had one — a call on your own media has no publisher, an unrouted call has no buyer or target.
| Field | Type | Meaning |
|---|---|---|
id | uuid | The call's id, used in every path below. |
ref | string or null | The public reference, such as CA-482137. It is what people quote to each other. |
startedAt | timestamp | When the call arrived. |
status | string | The call's status: initiated, routing, reserved, ringing, waiting, in_progress, completed, missed, rejected, abandoned, no_agent, no_answer, busy, failed or terminated. |
outcome | string | One word per call, most specific first: live (not ended), converted, unrouted, connected (a buyer answered), missed. |
callerNumber | string or null | The caller, in E.164. null when the caller hid their number. |
dialedNumber | string or null | The tracking number that was dialled. |
campaign, publisher, buyer, target | object or null | { id, name }, frozen on the call. |
talkSeconds | integer | Seconds the caller talked to the buyer. Counted from the moment the buyer's leg answered. |
ringSeconds | integer | Seconds spent ringing. |
converted | boolean | Whether the call earned its price — see Conversion. |
revenueCents | integer | What the buyer owes you for the call. A record, not a transfer. |
payoutCents | integer | What you owe the publisher for the call. A record, not a transfer. |
profitCents | integer | revenueCents less payoutCents. |
usageCents | integer | What the platform charged for the call's connected minutes, at your plan's per-minute rate. See pricing. |
reasonCode | string or null | The unrouted reason code the call ended under, else null. |
reasonSentence | string or null | That reason in words. The router's own sentence — it names the cap and the figure — when one was stored. |
hasRecording | boolean | Whether a recording exists. |
tags | object | The call's tags, as string values. |
The call in detail
Get a call and the three writes return the list row plus these fields.
| Field | Type | Meaning |
|---|---|---|
direction | string | inbound or outbound. |
attribution | string or null | How the call was tied to its source: tracking_number, routing_key, ping_window, direct or rtb_bid. |
routingMode | string or null | The routing mode in force when the call was routed. |
answeredAt | timestamp or null | When the buyer's leg answered. Never set by the platform picking up the caller. |
endedAt | timestamp or null | When the call ended. null while it is in progress. |
hungUpBy | string or null | caller, agent (the far end — the buyer, on a tracking call), platform or unknown. |
endedReason | string or null | The cause the call ended under. For an unrouted call it is the reason code. |
attemptCount | integer | How many targets were dialled. |
callerState, callerCity | string or null | The state comes from the caller's area code. The city is what a publisher sent with their ping. |
dialedNumberId, numberPoolId, dniSessionId | string or null | The tracking number, and the pool and visitor session when the call came through DNI. |
deliveryMode | string or null | How the call was delivered to the buyer. |
tagSources | object | Where each tag came from: publisher, dni, number, system, api or user. |
settlementMode | string | books_only for a tracking call. |
settledAt, convertedAt | timestamp or null | When the books were written, and when the call converted. |
conversionSource | string or null | duration, postback, ivr, manual or qc. |
frozen | object | The terms the books were written on: { revenueCents, payoutCents, durationThresholdSeconds }. A call that booked nothing on a priced target is explained by comparing the two. |
usageRateCents | number or null | The per-minute rate frozen on the call when it started — a rate, with two decimal places. |
corrections | object or null | Manual figures kept on the call: any of converted, revenueCents, payoutCents. They survive every later recompute. |
adjustments | array | The history of adjustments, oldest first, at most 50. |
conversionNote | string or null | The note sent with the last conversion. |
recordings | array | { id, format, channels, durationSeconds, createdAt, expiresAt, url }. url is the path of Stream a recording, relative to the API's origin. |
The waterfall
Get a call also returns story: the call explained, from records written while the call happened. It is the same account the console draws — see The call waterfall.
| Part | What it holds |
|---|---|
summary | { outcome, sentence } — the whole call in one sentence. |
steps | In order, each { at, step, title, sentence, tone, detail }. step is one of incoming, gates, plan, skipped, ping, dial, result, connected, unrouted, ended or settled; tone is info, success, warning or danger. There is one skipped step per target passed over, with its skip reason. |
attempts | One entry per dial: who was rung, for how long, the outcome and the SIP result in words. Destinations are masked — "the number ending 0188". |
pings | The buyers' bidding endpoints that were asked for a price, with status, bid, latency and reject reason. Empty when the plan has no RTB targets. |
money | The books: converted, revenueCents, payoutCents, profitCents, usageCents, usageRateCents, the frozen terms and the adjustments. |
events | The carrier's event trail as { id, at, source, type }, without payloads. |
sentence for a step you do not recognise rather than failing.Books-only writes
Three endpoints change what your reports say about a call: Post a conversion, Add or remove tags and Adjust a call's books. None of them moves money. Revenue and payout on a tracking call are records, not transfers — your buyers pay you, and you pay your publishers, outside the platform. Each write is recorded in the activity log under the key's name, and each is safe to retry. The guide is Conversions and adjustments.
Endpoints
- GET
/api/v1/callsList calls - GET
/api/v1/calls/liveList calls in progress - GET
/api/v1/calls/export.csvExport calls as CSV - GET
/api/v1/calls/:idGet a call - GET
/api/v1/calls/:id/recordings/:rid/audioStream a recording - POST
/api/v1/calls/:id/conversionPost a conversion - POST
/api/v1/calls/:id/tagsAdd or remove tags - POST
/api/v1/calls/:id/adjustAdjust a call's books
List calls
/api/v1/callsThe call log, filtered and paged. stats is computed over the same filter as the rows, across every page, so the totals are always about the list you asked for; total is stats.count. Both rates are fractions and are null when their denominator is zero: connectRate is connected ÷ calls, conversionRate is converted ÷ connected.
Query parameters
| Name | Type | Description |
|---|---|---|
fromOptional | date | Start of the window, as an ISO 8601 instant. A bare date is read as midnight UTC.default: the start of the day six days ago in tz, so the window covers the last 7 calendar days |
toOptional | date | End of the window.default: now · must not be before from · the window may span at most 366 days |
tzOptional | string | IANA time zone for the default window and for local timestamps, such as America/Chicago. An unrecognised zone is a 400.up to 64 characters · default: the workspace's time zone |
campaignIdOptional | uuid | Calls on one campaign. |
publisherIdOptional | uuid | Calls from one publisher. |
buyerIdOptional | uuid | Calls taken by one buyer. |
targetIdOptional | uuid | Calls taken by one target. |
numberIdOptional | uuid | Calls that arrived on one tracking number. |
statusOptional | enum | The call's status.initiated, routing, reserved, ringing, waiting, in_progress, completed, missed, rejected, abandoned, no_agent, no_answer, busy, failed, terminated |
outcomeOptional | enum | One word for how the call ended. As a filter, connected is every call a buyer answered, converted or not.connected, converted, unrouted, missed, live |
minTalkOptional | integer | Least talk time, in seconds.0 to 86400; must not exceed maxTalk |
maxTalkOptional | integer | Most talk time, in seconds.0 to 86400 |
callerOptional | string | A caller's number. A full number — E.164, or 10 or 11 digits — is an exact match; anything shorter matches the end of the number, so 0142 finds the caller ending 0142. In a query string, write the + of an E.164 number as %2B.up to 32 characters · at least 4 digits |
tagKeyOptional | string | Calls that carry this tag, whatever its value.letters, digits, ., - and _; up to 64 characters |
tagValueOptional | string | Calls whose tagKey tag has exactly this value. Needs tagKey.up to 512 characters |
reasonOptional | string | Calls that ended unrouted for this reason. Only the unrouted reason codes are accepted; a skip reason such as capped is a 400, because no call ends on one. |
hasRecordingOptional | boolean | true for calls with a recording, false for calls without. |
pageOptional | integer | Page number, from 1.default 1 |
pageSizeOptional | integer | Rows per page.default 25 · at most 200; a larger value is clamped |
sortOptional | enum | The field to sort by. An unknown value falls back to the default.startedAt, talkSeconds, billedCents (revenue) · default startedAt |
dirOptional | enum | Sort direction.asc, desc · default desc |
Example request
curl "https://api.buy3.io/api/v1/calls?outcome=converted&from=2026-09-14T05:00:00Z&tz=America/Chicago&pageSize=1" \
-H "Authorization: Bearer $BUY3_API_KEY"Responses
- 200A page of call objects, with
statsfor the whole filtered set and the window that was applied.usageCentsis left out of the example rows and ofstats.
{
"data": [
{
"id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"ref": "CA-482137",
"startedAt": "2026-09-19T14:03:11.000Z",
"status": "completed",
"outcome": "converted",
"callerNumber": "+14155550142",
"dialedNumber": "+18885550100",
"campaign": {
"id": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"name": "Health cover — inbound"
},
"publisher": {
"id": "c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b",
"name": "Northwind Media"
},
"buyer": {
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"name": "Acme Health"
},
"target": {
"id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"name": "Acme Health — Dallas floor"
},
"talkSeconds": 184,
"ringSeconds": 8,
"converted": true,
"revenueCents": 4200,
"payoutCents": 2750,
"profitCents": 1450,
"reasonCode": null,
"reasonSentence": null,
"hasRecording": true,
"tags": {
"gclid": "Cj0KCQjw",
"utm_source": "search"
}
}
],
"page": 1,
"pageSize": 1,
"total": 96,
"totalPages": 96,
"stats": {
"count": 96,
"connected": 96,
"converted": 96,
"unrouted": 0,
"connectRate": 1,
"conversionRate": 1,
"revenueCents": 403200,
"payoutCents": 264000,
"profitCents": 139200,
"avgTalkSeconds": 201,
"totalTalkSeconds": 19296
},
"from": "2026-09-14T05:00:00.000Z",
"to": "2026-09-20T18:22:09.412Z",
"timezone": "America/Chicago"
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:read, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 400 | validation_error | A filter has the wrong type: an id that is not a UUID, a status or outcome that is not in the list, a talk time out of range. details names the field. |
| 400 | bad_request | from is after to, the window is wider than 366 days, tz is not a time zone, tagValue was sent without tagKey, tagKey is not a valid key, reason is not an unrouted reason, minTalk exceeds maxTalk, or caller has fewer than 4 digits. details names the field. |
List calls in progress
/api/v1/calls/liveThe calls on the phone right now, built to be polled every few seconds. It takes no filters on purpose: a call in progress on a campaign you have filtered out is still in progress. It returns calls that have not ended, started in the last six hours, with the status routing, ringing or in_progress — newest first, at most 200. target and buyer follow the call down the routing plan, so they name who is being rung or spoken to at this moment. The response is sent with Cache-Control: no-store. The guide is Live calls.
Example request
curl "https://api.buy3.io/api/v1/calls/live" \
-H "Authorization: Bearer $BUY3_API_KEY"Responses
- 200
countis the number of calls inlive;connectedis how many of them a buyer has answered.elapsedSecondsandtalkSecondsare measured atat, the moment the response was built.
{
"live": [
{
"id": "2c4e6a80-1b3d-4f5a-9c7e-8d0b2a4c6e81",
"ref": "CA-482291",
"status": "in_progress",
"startedAt": "2026-09-20T18:21:02.000Z",
"answeredAt": "2026-09-20T18:21:14.000Z",
"elapsedSeconds": 67,
"talkSeconds": 55,
"callerNumber": "+14155550142",
"callerState": "CA",
"dialedNumber": "+18885550100",
"campaign": {
"id": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"name": "Health cover — inbound"
},
"publisher": {
"id": "c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b",
"name": "Northwind Media"
},
"target": {
"id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"name": "Acme Health — Dallas floor"
},
"buyer": {
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"name": "Acme Health"
},
"attemptNo": 1
}
],
"count": 1,
"connected": 1,
"at": "2026-09-20T18:22:09.412Z"
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:read, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
Export calls as CSV
/api/v1/calls/export.csvThe filtered call log as one CSV file, streamed from the server — far faster than paging for a large pull. It takes the same filters as List calls, without paging or sorting, and returns at most 100,000 rows, newest first. The file is UTF-8 with a byte-order mark and RFC 4180 quoting; tags is one cell holding a JSON object, and startedAtLocal is YYYY-MM-DD HH:MM:SS in the request's time zone. A cell that a spreadsheet would read as a formula is prefixed with an apostrophe. The export is written to the activity log before the first byte is sent, at high severity, with the filter and the row count under the key's name — a file of callers' numbers leaving the platform is an act with a name on it. If the stream fails part-way the connection is dropped rather than ending a truncated file cleanly, so a broken download fails visibly. See Exports.
Query parameters
| Name | Type | Description |
|---|---|---|
fromOptional | date | Start of the window, as an ISO 8601 instant. A bare date is read as midnight UTC.default: the start of the day six days ago in tz, so the window covers the last 7 calendar days |
toOptional | date | End of the window.default: now · must not be before from · the window may span at most 366 days |
tzOptional | string | IANA time zone for the default window and for local timestamps, such as America/Chicago. An unrecognised zone is a 400.up to 64 characters · default: the workspace's time zone |
campaignIdOptional | uuid | Calls on one campaign. |
publisherIdOptional | uuid | Calls from one publisher. |
buyerIdOptional | uuid | Calls taken by one buyer. |
targetIdOptional | uuid | Calls taken by one target. |
numberIdOptional | uuid | Calls that arrived on one tracking number. |
statusOptional | enum | The call's status.initiated, routing, reserved, ringing, waiting, in_progress, completed, missed, rejected, abandoned, no_agent, no_answer, busy, failed, terminated |
outcomeOptional | enum | One word for how the call ended. As a filter, connected is every call a buyer answered, converted or not.connected, converted, unrouted, missed, live |
minTalkOptional | integer | Least talk time, in seconds.0 to 86400; must not exceed maxTalk |
maxTalkOptional | integer | Most talk time, in seconds.0 to 86400 |
callerOptional | string | A caller's number. A full number — E.164, or 10 or 11 digits — is an exact match; anything shorter matches the end of the number, so 0142 finds the caller ending 0142. In a query string, write the + of an E.164 number as %2B.up to 32 characters · at least 4 digits |
tagKeyOptional | string | Calls that carry this tag, whatever its value.letters, digits, ., - and _; up to 64 characters |
tagValueOptional | string | Calls whose tagKey tag has exactly this value. Needs tagKey.up to 512 characters |
reasonOptional | string | Calls that ended unrouted for this reason. Only the unrouted reason codes are accepted; a skip reason such as capped is a 400, because no call ends on one. |
hasRecordingOptional | boolean | true for calls with a recording, false for calls without. |
Example request
curl "https://api.buy3.io/api/v1/calls/export.csv?outcome=converted&from=2026-09-01T05:00:00Z&tz=America/Chicago" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-o calls.csvResponses
- 200
Content-Type: text/csv; charset=utf-8, withContent-Disposition: attachment; filename="calls-YYYY-MM-DD.csv". The first line is the header below, then one line per call.
ref,startedAt,startedAtLocal,status,outcome,callerNumber,dialedNumber,campaign,publisher,buyer,target,ringSeconds,talkSeconds,converted,revenueCents,payoutCents,profitCents,usageCents,reasonCode,reasonSentence,hasRecording,tagsErrors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:read, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 400 | validation_error | A filter has the wrong type: an id that is not a UUID, a status or outcome that is not in the list, a talk time out of range. details names the field. |
| 400 | bad_request | from is after to, the window is wider than 366 days, tz is not a time zone, tagValue was sent without tagKey, tagKey is not a valid key, reason is not an unrouted reason, minTalk exceeds maxTalk, or caller has fewer than 4 digits. details names the field. |
Get a call
/api/v1/calls/:idEverything about one call: the list row, the detail fields, its recordings and story — the waterfall, with every check it passed, every target considered and why each was skipped, and every dial attempt with its result in words. Recording links point at /api/v1, so the key that read the call can follow them.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The call's id. The public reference (CA-482137) is not accepted here — find a call by reference with the list's filters and read its id. |
Example request
curl "https://api.buy3.io/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11" \
-H "Authorization: Bearer $BUY3_API_KEY"Responses
- 200
recordingsReasonsays whyrecordingsis empty:not_saved(the call was answered and nothing was kept — the campaign does not record, for instance),never_answered, ornullwhen there are recordings.usageCentsandusageRateCentsare left out of the example, on the call and instory.money.
{
"call": {
"id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"ref": "CA-482137",
"startedAt": "2026-09-19T14:03:11.000Z",
"status": "completed",
"outcome": "converted",
"callerNumber": "+14155550142",
"dialedNumber": "+18885550100",
"campaign": {
"id": "6a2d1f0e-8b7c-4c55-a1d3-0e9f8a7b6c5d",
"name": "Health cover — inbound"
},
"publisher": {
"id": "c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b",
"name": "Northwind Media"
},
"buyer": {
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"name": "Acme Health"
},
"target": {
"id": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"name": "Acme Health — Dallas floor"
},
"talkSeconds": 184,
"ringSeconds": 8,
"converted": true,
"revenueCents": 4200,
"payoutCents": 2750,
"profitCents": 1450,
"reasonCode": null,
"reasonSentence": null,
"hasRecording": true,
"tags": {
"gclid": "Cj0KCQjw",
"utm_source": "search"
},
"direction": "inbound",
"attribution": "tracking_number",
"routingMode": "priority",
"answeredAt": "2026-09-19T14:03:38.000Z",
"endedAt": "2026-09-19T14:06:42.000Z",
"hungUpBy": "caller",
"endedReason": "normal_clearing",
"attemptCount": 2,
"callerState": "CA",
"callerCity": null,
"dialedNumberId": "7c0e5a3b-1d2f-4e6a-8b9c-0d1e2f3a4b5c",
"numberPoolId": null,
"dniSessionId": null,
"deliveryMode": "pstn_did",
"tagSources": {
"gclid": "dni",
"utm_source": "dni"
},
"settlementMode": "books_only",
"settledAt": "2026-09-19T14:06:43.000Z",
"convertedAt": "2026-09-19T14:06:43.000Z",
"conversionSource": "duration",
"frozen": {
"revenueCents": 4200,
"payoutCents": 2750,
"durationThresholdSeconds": 90
},
"corrections": null,
"adjustments": [],
"conversionNote": null,
"recordings": [
{
"id": "3f2e1d0c-9b8a-4c7d-a6e5-f4d3c2b1a098",
"format": "mp3",
"channels": 2,
"durationSeconds": 184,
"createdAt": "2026-09-19T14:06:50.000Z",
"expiresAt": null,
"url": "/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11/recordings/3f2e1d0c-9b8a-4c7d-a6e5-f4d3c2b1a098/audio"
}
]
},
"story": {
"summary": {
"outcome": "converted",
"sentence": "Connected to Acme Health — Dallas floor (Acme Health) on the second attempt and talked for 3 minutes 4 seconds. It converted for $42.00, with $27.50 owed to the publisher."
},
"steps": [
{
"at": "2026-09-19T14:03:11.000Z",
"step": "incoming",
"title": "Call received",
"sentence": "A call from +14155550142 (CA) arrived on +18885550100 for the campaign Health cover — inbound, from the publisher Northwind Media. It carried 2 tags.",
"tone": "info",
"detail": {
"callerNumber": "+14155550142",
"callerState": "CA",
"dialedNumber": "+18885550100",
"numberId": "7c0e5a3b-1d2f-4e6a-8b9c-0d1e2f3a4b5c",
"publisherId": "c4e1a2b3-9d8f-4f6e-b5a4-1c2d3e4f5a6b",
"poolId": null,
"sessionId": null,
"tags": {
"gclid": "Cj0KCQjw",
"utm_source": "search"
},
"tagSources": {
"gclid": "dni",
"utm_source": "dni"
}
}
},
{
"at": "2026-09-19T14:03:11.000Z",
"step": "gates",
"title": "Checks passed",
"sentence": "The call passed every check before routing.",
"tone": "success",
"detail": {
"passed": [
"workspace_inactive",
"workspace_unfunded",
"campaign_not_live",
"caller_blocked",
"anonymous_blocked",
"campaign_closed",
"campaign_capped",
"repeat_blocked"
],
"failed": null
}
},
{
"at": "2026-09-19T14:03:11.000Z",
"step": "plan",
"title": "Routing plan",
"sentence": "2 targets were considered, in priority order: 2 could take the call. First in line: BlueSky Legal — overflow (BlueSky Legal).",
"tone": "info",
"detail": {
"mode": "priority",
"eligible": [
{
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"targetName": "BlueSky Legal — overflow",
"buyerId": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"buyerName": "BlueSky Legal",
"priority": 1,
"weight": 1,
"revenueCents": 3500,
"source": "static"
},
{
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"buyerId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"buyerName": "Acme Health",
"priority": 2,
"weight": 1,
"revenueCents": 4200,
"source": "static"
}
],
"skipped": []
}
},
{
"at": "2026-09-19T14:03:12.000Z",
"step": "dial",
"title": "Attempt 1 · BlueSky Legal — overflow",
"sentence": "Rang BlueSky Legal — overflow (BlueSky Legal) on the number ending 0147 — no answer after 18 seconds (SIP 480, the line was unavailable).",
"tone": "warning",
"detail": {
"attemptNo": 1,
"outcome": "no_answer",
"rangSeconds": 18,
"endedAt": "2026-09-19T14:03:30.000Z",
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"targetName": "BlueSky Legal — overflow",
"buyerId": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"buyerName": "BlueSky Legal",
"deliveryMode": "pstn_did",
"destination": "the number ending 0147",
"sipCode": 480,
"sipText": "the line was unavailable"
}
},
{
"at": "2026-09-19T14:03:30.000Z",
"step": "dial",
"title": "Attempt 2 · Acme Health — Dallas floor",
"sentence": "Rang Acme Health — Dallas floor (Acme Health) on the number ending 0188 — answered.",
"tone": "success",
"detail": {
"attemptNo": 2,
"outcome": "answered",
"rangSeconds": 8,
"endedAt": "2026-09-19T14:03:38.000Z",
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"buyerId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"buyerName": "Acme Health",
"deliveryMode": "pstn_did",
"destination": "the number ending 0188",
"sipCode": null,
"sipText": null
}
},
{
"at": "2026-09-19T14:03:38.000Z",
"step": "connected",
"title": "Connected",
"sentence": "Acme Health — Dallas floor (Acme Health) answered on the second attempt, 27 seconds after the call arrived.",
"tone": "success",
"detail": {
"attemptNo": 2,
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"secondsToConnect": 27
}
},
{
"at": "2026-09-19T14:06:42.000Z",
"step": "ended",
"title": "Call ended",
"sentence": "The call ended after 3 minutes 4 seconds of talk time — the caller hung up.",
"tone": "info",
"detail": {
"talkSeconds": 184,
"hungUpBy": "caller",
"hasRecording": true
}
},
{
"at": "2026-09-19T14:06:43.000Z",
"step": "settled",
"title": "Converted",
"sentence": "The call converted: $42.00 in revenue from Acme Health, $27.50 owed to the publisher Northwind Media — $14.50 profit.",
"tone": "success",
"detail": {
"converted": true,
"revenueCents": 4200,
"payoutCents": 2750,
"profitCents": 1450,
"durationThresholdSeconds": 90,
"conversionSource": "duration",
"corrected": false
}
}
],
"attempts": [
{
"attemptNo": 1,
"startedAt": "2026-09-19T14:03:12.000Z",
"endedAt": "2026-09-19T14:03:30.000Z",
"rangSeconds": 18,
"outcome": "no_answer",
"result": "no answer after 18 seconds (SIP 480, the line was unavailable)",
"tone": "warning",
"targetId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"targetName": "BlueSky Legal — overflow",
"buyerId": "e5d4c3b2-a1f0-4e9d-8c7b-6a5f4e3d2c1b",
"buyerName": "BlueSky Legal",
"agentName": null,
"deliveryMode": "pstn_did",
"destination": "the number ending 0147",
"sipCode": 480,
"sipText": "the line was unavailable",
"lossCode": null,
"whisperAccepted": null
},
{
"attemptNo": 2,
"startedAt": "2026-09-19T14:03:30.000Z",
"endedAt": "2026-09-19T14:03:38.000Z",
"rangSeconds": 8,
"outcome": "answered",
"result": "answered",
"tone": "success",
"targetId": "a9b8c7d6-e5f4-4a3b-9c2d-1e0f9a8b7c6d",
"targetName": "Acme Health — Dallas floor",
"buyerId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"buyerName": "Acme Health",
"agentName": null,
"deliveryMode": "pstn_did",
"destination": "the number ending 0188",
"sipCode": null,
"sipText": null,
"lossCode": null,
"whisperAccepted": null
}
],
"pings": [],
"money": {
"settled": true,
"settledAt": "2026-09-19T14:06:43.000Z",
"settlementMode": "books_only",
"converted": true,
"convertedAt": "2026-09-19T14:06:43.000Z",
"conversionSource": "duration",
"revenueCents": 4200,
"payoutCents": 2750,
"profitCents": 1450,
"durationThresholdSeconds": 90,
"frozen": {
"revenueCents": 4200,
"payoutCents": 2750
},
"adjustments": []
},
"events": [
{
"id": "918273",
"at": "2026-09-19T14:03:11.000Z",
"source": "telnyx",
"type": "call.initiated"
},
{
"id": "918301",
"at": "2026-09-19T14:06:42.000Z",
"source": "telnyx",
"type": "call.hangup"
}
]
},
"recordingsReason": null
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:read, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 404 | not_found | No tracking call with that id in your workspace. A malformed id and another workspace's call answer exactly the same. |
Stream a recording
/api/v1/calls/:id/recordings/:rid/audioThe audio of one recording. It is streamed through the platform: the carrier's own link never leaves the server, so there is no public URL to a recording and nothing to leak by forwarding one. Take the path from call.recordings[].url. See Recordings.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The call's id. The public reference (CA-482137) is not accepted here — find a call by reference with the list's filters and read its id. |
ridRequired | uuid | The recording's id, from call.recordings. |
Example request
curl "https://api.buy3.io/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11/recordings/3f2e1d0c-9b8a-4c7d-a6e5-f4d3c2b1a098/audio" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-o CA-482137.mp3Responses
- 200The audio bytes, with
Content-Type: audio/*,Content-Length,Cache-Control: private, max-age=3600andContent-Disposition: inline; filename="<ref>.<format>".
(binary audio)Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:read, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 404 | not_found | No such call, no such recording on that call, or a recording with no audio stored against it. A malformed id or rid answers the same. |
| 410 | error | The carrier no longer holds the audio. |
| 502 | upstream_error | The carrier refused the request or could not be reached. Safe to retry. |
Post a conversion
/api/v1/calls/:id/conversionTell the books that a call sold — or that it did not. Use it when the sale happens in your own system, after the call, and only you know which calls paid. Books only: no wallet moves. converted: false is the one way a converted call stops being one; the platform never revokes a conversion on its own. The revenue booked is 0 when converted is false, revenueCents when you send it, and otherwise the call's frozen price — or an earlier manual figure, which stands. The publisher's payout is recomputed on the terms frozen on the call. The call's conversionSource becomes manual, a call.converted webhook fires when the call newly converts, and the change is written to the activity log under the key's name. Safe to retry: it sets the books, so a repeat changes nothing.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The call's id. The public reference (CA-482137) is not accepted here — find a call by reference with the list's filters and read its id. |
Request body
| Name | Type | Description |
|---|---|---|
convertedRequired | boolean | Whether the call converted. |
revenueCentsOptional | integer | What the buyer owes for this call, overriding the frozen price. The figure is kept through any later recompute.0 to 100000000 · must be 0 or omitted when converted is false |
noteOptional | string | Why, in your own words. Returned as conversionNote and kept in the activity log.up to 500 characters |
Example request
curl -X POST "https://api.buy3.io/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11/conversion" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"converted": true,
"revenueCents": 5500,
"note": "Policy bound — CRM sale 88231"
}'Responses
- 200The call in detail, with the books
beforeandafter. The example showscallcut down to the fields that changed; the response carries all of them.
{
"call": {
"id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"ref": "CA-482137",
"outcome": "converted",
"converted": true,
"revenueCents": 5500,
"payoutCents": 2750,
"profitCents": 2750,
"convertedAt": "2026-09-20T15:40:12.000Z",
"conversionSource": "manual",
"corrections": {
"converted": true,
"revenueCents": 5500
},
"conversionNote": "Policy bound — CRM sale 88231"
},
"before": {
"converted": false,
"revenueCents": 0,
"payoutCents": 0,
"convertedAt": null
},
"after": {
"converted": true,
"revenueCents": 5500,
"payoutCents": 2750,
"convertedAt": "2026-09-20T15:40:12.000Z"
}
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:write, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 400 | validation_error | converted is missing or not a boolean, revenueCents is out of range, or note is too long. |
| 400 | bad_request | revenueCents is above 0 while converted is false. details.revenueCents says so. |
| 404 | not_found | No tracking call with that id in your workspace. A malformed id and another workspace's call answer exactly the same. |
| 409 | conflict | The call is still in progress, it does not settle books-only, it changed while this was being saved, or its books could not be recomputed. Wait for the call to end, then send the request again. |
Add or remove tags
/api/v1/calls/:id/tagsAttach what you learnt about a call after it arrived — a CRM id, a disposition, a lead score — so reports can group by it and pixels can send it. Tags are merged into what the call already carries, in one statement, so two writers in the same second both keep their tags. Send null as a value to remove a key. Every tag set here is stamped with the source api, so it can never pass for something the publisher sent or the platform measured. It works on a call that is still in progress. Written to the activity log, at low severity.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The call's id. The public reference (CA-482137) is not accepted here — find a call by reference with the list's filters and read its id. |
Request body
| Name | Type | Description |
|---|---|---|
tagsRequired | object | Keys and values to set. A value is a string, a number or a boolean, stored as a string and cut at 512 characters — or null to remove the key.1 to 40 keys per request · key: letters, digits, ., - and _, up to 64 characters · a call carries at most 80 tags |
Example request
curl -X POST "https://api.buy3.io/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11/tags" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tags": {
"crm_id": "88231",
"disposition": "sold",
"lead_score": null
}
}'Responses
- 200The call's full tag set after the merge, and where each tag came from.
{
"id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"tags": {
"gclid": "Cj0KCQjw",
"utm_source": "search",
"crm_id": "88231",
"disposition": "sold"
},
"tagSources": {
"gclid": "dni",
"utm_source": "dni",
"crm_id": "api",
"disposition": "api"
}
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:write, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 400 | validation_error | tags is missing or is not an object. |
| 400 | bad_request | tags is empty or has more than 40 keys, a key has characters that are not allowed, a value is an object or an array, or the call would carry more than 80 tags. details.tags says which. |
| 404 | not_found | No tracking call with that id in your workspace. A malformed id and another workspace's call answer exactly the same. |
Adjust a call's books
/api/v1/calls/:id/adjustCorrect what a call's revenue or payout says, with a reason: the buyer short-paid, you agreed a make-good with the publisher. Books only: no wallet moves. A number sets and keeps the figure through any later recompute. An explicit JSON null clears a kept correction, and the call books what its frozen terms say again — the buyer's price if it converted, nothing if it did not. A field you do not send is left alone; this is the one endpoint where null and "not sent" differ, and an empty string does not count as either. Each adjustment is appended to the call's adjustments — who, when, why, from what, to what — and written to the activity log under the key's name. A call takes at most 50 adjustments. Safe to retry: a repeat changes no figure, and adds one more line of history.
Path parameters
| Name | Type | Description |
|---|---|---|
idRequired | uuid | The call's id. The public reference (CA-482137) is not accepted here — find a call by reference with the list's filters and read its id. |
Request body
| Name | Type | Description |
|---|---|---|
revenueCentsOptional | integer or null | What the buyer owes for this call. null clears a kept correction.0 to 100000000 |
payoutCentsOptional | integer or null | What you owe the publisher for this call. null clears a kept correction.0 to 100000000 |
reasonRequired | string | Why the books are being changed. Kept with the adjustment.3 to 500 characters |
Example request
curl -X POST "https://api.buy3.io/api/v1/calls/0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11/adjust" \
-H "Authorization: Bearer $BUY3_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"revenueCents": 3000,
"reason": "Buyer short-paid — invoice 2291"
}'Responses
- 200The call in detail and the adjustment that was recorded.
adjustment.clearedis present only when a side was cleared, and lists which. The example showscallcut down to the fields that changed.
{
"call": {
"id": "0b9f6c1e-52a4-4a0e-9f0a-3d1c7e8b2a11",
"ref": "CA-482137",
"converted": true,
"revenueCents": 3000,
"payoutCents": 2750,
"profitCents": 250,
"corrections": {
"revenueCents": 3000
}
},
"adjustment": {
"at": "2026-09-21T09:12:44.103Z",
"by": {
"kind": "api_key",
"id": "5b1c9a70-2e4d-4f3a-9c8b-7d6e5f4a3b2c",
"name": "API key b3_live_Qx7m"
},
"reason": "Buyer short-paid — invoice 2291",
"before": {
"revenueCents": 4200,
"payoutCents": 2750
},
"after": {
"revenueCents": 3000,
"payoutCents": 2750
}
}
}Errors
| Status | Code | When |
|---|---|---|
| 401 | missing_key, invalid_key | No bearer key was sent, or the key is malformed, unknown or revoked. See Authentication. |
| 402 | subscription_inactive, plan_upgrade_required | The workspace's subscription is not active, or its plan does not include call tracking and API access. |
| 403 | insufficient_scope, ip_not_allowed | The key does not hold calls:write, or the request came from outside the key's IP allowlist. |
| 429 | rate_limited | The key is over its per-minute limit. Wait for Retry-After seconds. |
| 400 | validation_error | reason is missing, shorter than 3 characters or longer than 500, or a figure is out of range. |
| 400 | bad_request | Neither revenueCents nor payoutCents was sent — as a number, or as an explicit null. The message is "Say what to adjust". |
| 404 | not_found | No tracking call with that id in your workspace. A malformed id and another workspace's call answer exactly the same. |
| 409 | conflict | The call is still in progress, it does not settle books-only, it has already been adjusted 50 times, or its books could not be recomputed. |
