Appearance
Report builder
Español: Constructor de reportes.
The report builder answers questions about the calls a tenant placed, over a closed catalog of dimensions and metrics. Three routes carry it: GET /v1/report-catalog (the vocabulary), POST /v1/reports/run (one aggregate, or one page of attempts) and /v1/report-definitions (saved queries). Field names, enums, limits and error codes below are copied from the vendored OpenAPI document (core v0.105.0, openapi/dialer-v1.yaml); the API Reference generated from it is the authority on shapes.
Availability
| Capability | Spec operation |
|---|---|
| Read the catalog | getReportCatalog |
| Run an aggregate or a detail page, JSON or CSV | runReport |
| Saved definitions: list, create, read, replace, delete | listReportDefinitions, createReportDefinition, getReportDefinition, replaceReportDefinition, deleteReportDefinition |
Running is gated by the tenant flag report_builder_enabled: with the flag off, POST /v1/reports/run answers 503 report_builder_disabled and nothing is read (runReport). The catalog keeps answering with the flag off (getReportCatalog), and the saved definitions are not gated by it (listReportDefinitions). Do not assume the flag is on for your tenant: call the run route and handle the 503.
Credentials: the routes are tenant-scoped, so a dd_ key or a dashboard login works (runReport). A dds_ support session is GET-only and cannot run reports.
The catalog
bash
curl -s "$API/v1/report-catalog" -H "Authorization: Bearer $TOKEN"The 200 body is the ReportCatalog schema: fact (always call_attempts), version (it bumps when a published definition changes), dimensions, metrics, detail_columns and limits.
- The unit is the attempt: the original dial the platform placed plus the corrections appended to it.
dispositionis that of the last settling correction, andin_flightwhile there is none (getReportCatalog). - Dimensions published in core
v0.105.0:campaign_id,agent_id,disposition,source,trunk_kind,amd_verdict,ai_used,hangup_cause,day,week,month. - Metrics:
attempts,contacts,contact_rate_bp(basis points),accounts_attempted,talk_seconds,ai_calls. Each metric has one definition, shared with the fixed counters ofGET /v1/stats/summary. - No dimension carries personal data:
piiisnullon every one. - Limits (the example in
getReportCatalog):max_window_days: 31,max_groups: 1000,max_dimensions: 4,max_filter_values: 100,max_detail_rows: 1000. Read the live values from the catalog rather than hard-coding them.
The key lists above come from lib/dialer/reports/catalog.ex at the v0.105.0 tag of core; treat GET /v1/report-catalog as the source of truth, because a later version can add keys.
Run an aggregate
bash
curl -s -X POST "$API/v1/reports/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"from": "2026-10-01T00:00:00Z",
"to": "2026-10-08T00:00:00Z",
"dimensions": ["campaign_id", "disposition"],
"metrics": ["attempts", "contacts", "contact_rate_bp"],
"filters": { "source": ["platform"] },
"zone": "UTC"
}'Rules from runReport and the ReportRunRequest schema:
fromandtoare mandatory RFC 3339 timestamps. The window is[from, to)onstarted_atand at most 31 days.- Up to 4
dimensions(none gives a single totals row) and at least one metric. filtersmaps a filterable dimension to a non-empty list of at most 100 values.zonedefaults toUTC; it is an IANAArea/Locationname and sets the calendar ofday,weekandmonth.- Any field outside
from,to,dimensions,metrics,filtersandzone(for exampletenant_id) is422 unknown_field: the tenant is the caller's. contact_rate_bpis contacts over attempts in basis points, rounded half up, andnull(never0) when there are no attempts.
The 200 body is the ReportRunResult schema: fact, catalog_version, from, to, zone, columns (each with kind dimension or metric), row_count and rows.
CSV
Add ?format=csv. An Accept header alone is not enough (runReport). The CSV has one header row of column keys, neutralises spreadsheet formulas and carries the header X-Artifact-SHA256, the SHA-256 of the CSV body. Compare it with your own hash of the body to detect a truncated or altered download:
bash
curl -s -D headers.txt -o report.csv -X POST "$API/v1/reports/run?format=csv" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"from":"2026-10-01T00:00:00Z","to":"2026-10-08T00:00:00Z","metrics":["attempts"]}'
grep -i '^x-artifact-sha256' headers.txt
sha256sum report.csvDetail mode: one row per attempt
"mode": "detail" answers one row per placed attempt instead of a group (runReport). It takes columns (from detail_columns of the catalog), filters, unmask, limit (default and maximum 1000) and after.
json
{ "mode": "detail", "from": "2026-10-01T00:00:00Z", "to": "2026-10-02T00:00:00Z",
"columns": ["attempt_id", "to_number"], "limit": 500 }- Pagination is a keyset over
(started_at, id), oldest first. The page carriesnext_cursor; send it asafterfor the next page.next_cursorisnullon the last page. A malformedafteris400 invalid_cursor. dimensions,metricsandzoneare aggregate-only;columns,unmask,limitandafterare detail-only. The wrong mode's key is422 field_not_for_mode.- The free-text
noteandcompliance_snapshotare not columns. - With
?format=csv, every row carries anexport_cursorcolumn (the last one is theafterof the next page) and the response carriesX-Artifact-SHA256.
Transfer columns
Two detail columns describe a transferred call. They are on core main (core#2403, core ADR #297), after the v0.129.0 tag, and the catalog version is 3 there (@version 3 in lib/dialer/reports/catalog.ex); the definitions are in the same file, key transfer_count and key transfer_chain. Read detail_columns of the live catalog to see whether your deployment has them.
transfer_count(integer): the control hand-offs of the call, the appliedcontrol_handoffrows ofcall_control_actions.0for a call nobody transferred.transfer_chain(string): who held the call, in order: the agent it started with (unassignedwhen it started with none), then the agent of each hand-off, joined by>;externalwhere control left the agent population.nullfor a call nobody transferred. The legs with role and duration aretransfer_chainofGET /v1/calls/{call_id}(see the REST API guide).
Neither column carries personal data (pii is null). In CSV they are plain columns like the rest.
Phone masking and unmask
A column whose pii is phone or identifier is masked by default: *** plus the last four characters, read from the stored mask without decrypting anything (getReportCatalog).
"unmask": true returns those columns whole. It is allowed only for a dd_ key or a ddu_ login with the account_admin role. Otherwise (runReport):
| Caller | Answer |
|---|---|
member role | 403 role_forbidden (never a silently masked answer) |
dds_ support session | 403 forbidden |
unmask with no PII column in columns | 422 unmask_without_pii_column |
Every unmasked page writes one row to an append-only evidence table in the same transaction as the read: actor, the definition, the columns, the unmasked columns and the row count, never the values. If that row cannot be written, nothing is returned. In CSV an unmasked number comes out behind the formula guard (prefixed with '). The unmasked field of the response lists the PII columns returned whole, and is empty when they were masked (ReportRunResult).
Saved definitions
A definition is a query over the catalog (dimensions, metrics, filters), never its result: the window and the zone are parameters of each run (listReportDefinitions).
| Route | Purpose |
|---|---|
GET /v1/report-definitions | List the tenant's definitions, by name |
POST /v1/report-definitions | Save one; 201 with { "report_definition": {...} } |
GET /v1/report-definitions/{id} | Read one |
PUT /v1/report-definitions/{id} | Full replacement of name, dimensions, metrics, filters |
DELETE /v1/report-definitions/{id} | Delete; 204 |
- The body is validated against the catalog exactly like an inline run. A key the catalog does not publish is
422and the message names it. nameis 1 to 120 characters and unique per tenant: a duplicate is409 name_taken.- The tenant and author are the caller's.
tenant_idorcreated_byin the body is422 unknown_field.created_byisnullwhen add_key saved it. factandcatalog_versionare stamped by the server; aPUTrefreshescatalog_version.- Tenant isolation. The definitions table has row-level security with a
tenant_isolationpolicy on the tenant id, so another tenant's definition is a404, not a403(runReport; migrationcreate_report_definitionsin corev0.105.0).
Run a saved definition with definition_id in place of dimensions, metrics and filters:
json
{ "definition_id": "<uuid>", "from": "2026-10-01T00:00:00Z", "to": "2026-10-08T00:00:00Z" }from, to and zone are always the run's own. A run by definition_id is always an aggregate.
Errors
Errors use the standard envelope { "error": { "code", "message" } }. As the contract returns them (runReport):
| Status | code | When |
|---|---|---|
| 400 | window_too_large | The window is wider than 31 days. A to not after from, or a missing or non-RFC 3339 bound, is also 400 |
| 400 | invalid_cursor | after is not a valid cursor |
| 422 | too_many_groups | The answer would exceed 1000 groups. The request is refused whole, never cut silently |
| 422 | catalog_changed | A saved definition names a key the catalog no longer publishes. The message names it and nothing is answered, not even a partial result |
| 422 | definition_conflict | definition_id together with dimensions, metrics or filters |
| 422 | invalid_definition_id | Malformed definition_id |
| 422 | unknown_field, unknown_dimension, unknown_metric, unknown_column, unknown_filter | A key outside the closed vocabulary |
| 422 | invalid_dimensions, invalid_metrics, invalid_columns, invalid_filters, invalid_zone, invalid_mode, invalid_limit, invalid_unmask | A list bound or a value out of range |
| 422 | field_not_for_mode | A detail-only key in an aggregate or the reverse |
| 403 | role_forbidden | unmask: true from a member |
| 404 | (not found) | Unknown definition_id, or another tenant's |
| 429 | rate_limited | Over the per-principal brake; honour Retry-After |
| 503 | report_builder_disabled | The tenant flag is off |
| 503 | report_timeout | The read exceeded its statement timeout |
On catalog_changed, open the catalog, update the saved definition with a PUT that uses current keys, and run again.