Appearance
Build a KYB Dashboard with the Metrics API
By the end of this guide you can pull the numbers behind a KYB (Know Your Business) dashboard straight from CryptoComply: how many business applications arrive and get decided each week, how long reviews take, and which reviews are still open. It takes about fifteen minutes.
The Metrics API has two endpoints under your organization:
| Endpoint | What it does |
|---|---|
GET /api/orgs/{org_id}/analytics/metrics/ | The catalog: every metric and dimension your organization can report on, and the limits a report must stay within. |
POST /api/orgs/{org_id}/analytics/report/ | Runs a report: the metrics you name, over a date range, bucketed by day, week or month, grouped and filtered. |
Every path is relative to https://api.cryptocomply.xyz. Find your org_id with GET /api/orgs/.
1. Get a read-only API key
CryptoComply issues API keys on request. Contact the CryptoComply team and ask for a key scoped to the analytics.read permission for your organization. That is all a dashboard needs: the key works only in your organization and can read metrics and nothing else.
The key is shown once, when it is created, so store it in a secret manager. It expires after a year unless it is revoked sooner. See Authentication & API keys for rotation.
2. Authenticate every request
Send the key in the Authorization header with the Api-Key prefix:
Authorization: Api-Key <key>bash
curl https://api.cryptocomply.xyz/api/orgs/ \
-H "Authorization: Api-Key $CRYPTOCOMPLY_API_KEY"3. Discover the metrics
Ask the catalog what your organization can report on:
bash
curl https://api.cryptocomply.xyz/api/orgs/42/analytics/metrics/ \
-H "Authorization: Api-Key $CRYPTOCOMPLY_API_KEY"json
{
"registry_version": 1,
"metrics": [
{
"id": "profiles.time_to_decision_p50",
"label": "Time to Decision (Median)",
"description": "Median time from profile creation to its first Approved or Declined status.",
"type": "duration",
"unit": "seconds",
"aggregation": "p50",
"time_field": "first decision",
"granularities": ["day", "week", "month"],
"dimensions": ["profile.type", "profile.group", "profile.review_status", "profile.relationship_status", "profile.risk", "profile.creation_source"],
"additive_over_time": false,
"data_available": { "from": "2025-12-17" },
"deprecated_at": null,
"replaced_by": null
}
],
"dimensions": [
{
"id": "profile.type",
"label": "Profile Type",
"description": "Whether the profile is an individual (KYC) or an entity (KYB).",
"type": "enum",
"values": [
{ "value": "individual", "label": "Individual" },
{ "value": "entity", "label": "Entity" }
]
}
],
"limits": { "max_range_days": 366, "max_dimensions": 2, "max_rows": 5000 }
}What to look at for each metric:
idis what you send in a report. It is stable: if a metric's meaning changes, it gets a newid, and the old one showsdeprecated_atandreplaced_by.labelis a ready-made chart title.unitiscount,ratio(a share from 0 to 1) orseconds.time_fieldsays when a row counts —created,first decision,closed, ornowfor a current count such as open reviews.granularitieslists the bucket widths the metric takes. It is empty for a current count, which you read without buckets.dimensionslists what you can group and filter the metric by.additive_over_timeisfalsefor rates, percentiles and distinct counts: don't add their buckets up — ask for totals instead.data_available.fromis the first day with data. Earlier buckets read zero.
Each dimension lists every value it takes in your organization, such as your group names or the review statuses (PR Pending Review, UR Under Review, RS RFI Sent, RR RFI Responded, ES Escalated, AP Approved, DE Declined, AR Archived).
Metrics for optional features, such as adverse media, appear only if your organization uses them.
The metrics a KYB dashboard usually needs:
| Metric | Label | What it counts |
|---|---|---|
profiles.created | Profiles Created | Profiles added, by when they were created. Related parties are not counted. |
profiles.decided | Profiles Decided | Profiles that reached their first decision, Approved or Declined, by when they did. |
profiles.approval_rate | Approval Rate | Of the profiles decided, the share first Approved. |
profiles.time_to_decision_p50 / _p90 | Time to Decision (Median / 90th Percentile) | Time from creation to the first decision. |
profiles.pending_review | Profiles Pending Review | Profiles awaiting a decision now: Pending Review, Under Review, RFI Sent, RFI Responded or Escalated. |
cohort.completion_rate, cohort.turnaround_p50 / _p90 | Completion Rate, Turnaround | Profiles received in each bucket and what has happened to them so far. |
alerts.open, rfis.open, rfis.overdue | Open Alerts, Open RFIs, Overdue RFIs | Open work now. |
4. Run a report
A report is a POST with a JSON body. Only metrics is required:
json
{
"metrics": ["profiles.created", "profiles.decided"],
"dimensions": ["profile.group"],
"filters": [{ "dimension": "profile.type", "op": "eq", "values": ["entity"] }],
"time": {
"from": "2026-07-01",
"to": "2026-09-30",
"granularity": "week",
"timezone": "America/New_York",
"compare": "previous_period"
},
"order": [{ "by": "bucket", "dir": "asc" }],
"limit": 500,
"include": ["totals"]
}| Field | What it does |
|---|---|
metrics | Up to 20 metric IDs from the catalog. Each becomes a column. |
dimensions | Up to 2 dimension IDs to group by. Every requested metric must take them. |
filters | Rows must pass every filter. op is eq (exactly one value), in (any of the values) or not_in (none of them). Up to 50 values each. |
time.from, time.to | The first and last day, both included, read in time.timezone. Send both or neither: without them, a report covers the last 30 days. |
time.granularity | day, week (from Monday) or month. Leave it out to fold the whole range into one row per group — the only way to read a current count. |
time.timezone | An IANA timezone such as America/New_York, so "Monday" and "October" mean what they mean to your team. Default UTC. |
time.compare | previous_period also computes the period just before: a range of whole calendar months compares with as many months before it, any other range with as many days. |
order | Sort keys: a requested metric, a dimension or bucket, with dir asc (default) or desc. Without it, rows sort by bucket, then by each dimension. |
limit | Most rows to return after sorting, up to 5000 (the default). |
include | ["totals"] adds every metric recomputed over the whole range. |
KYB means entities
Filter on profile.type = entity to keep only businesses. The filter narrows every profile, alert, hit and RFI metric in the report to KYB.
5. Read the response
The answer to the request above, with one of its rows shown:
json
{
"meta": {
"as_of": "2026-10-16T14:02:11Z",
"cached": false,
"refresh_retry_after": null,
"timezone": "America/New_York",
"range": { "from": "2026-07-01", "to": "2026-09-30" },
"compare_range": { "from": "2026-04-01", "to": "2026-06-30" },
"granularity": "week",
"row_count": 28,
"truncated": false,
"registry_version": 1
},
"columns": [
{ "name": "bucket", "label": "Bucket", "kind": "time", "type": "date", "unit": null },
{ "name": "profile.group", "label": "Profile Group", "kind": "dimension", "type": "enum", "unit": null },
{ "name": "profiles.created", "label": "Profiles Created", "kind": "metric", "type": "integer", "unit": "count" },
{ "name": "profiles.decided", "label": "Profiles Decided", "kind": "metric", "type": "integer", "unit": "count" }
],
"rows": [
{
"bucket": "2026-06-29",
"profile.group": "Partners",
"profiles.created": 11,
"profiles.decided": 9,
"compare": { "profiles.created": 8, "profiles.decided": 7 },
"partial": false
}
],
"totals": {
"profiles.created": 164,
"profiles.decided": 151,
"compare": { "profiles.created": 139, "profiles.decided": 128 }
}
}metasays how the report was computed: therangeandcompare_rangeit covered, thetimezoneandgranularity, how many rows came back, and whetherlimitcut any (truncated).columnsdescribes each key in a row, in order — uselabelfor headers andunitto format values.rowshold one entry per bucket and group.bucketis the bucket's first day: a Monday for weeks, the 1st for months. Empty buckets are filled with zeros, so your chart has no gaps. A metric is0when nothing happened, andnullwhen it is undefined — a rate or median of no profiles.compareon each row holds the same metrics for the matching bucket of the previous period, when you asked for a comparison.partialistruefor the bucket that is still running, such as the current week: its figures will grow. Show it differently, or leave it out of trends.totalsare each metric recomputed over the whole range, never summed from the rows. Use them for headline numbers, especially for medians and rates.as_ofis when the figures were computed. Reports are cached for up to an hour, andcachedsays whether this one came from the cache, so polling more often than that won't give you fresher numbers. A key always gets the cached report when one is held.
The first and last buckets only count the days inside your range, so start a weekly range on a Monday, and a monthly one on the 1st, to get whole buckets.
Worked examples
Weekly applications received vs completed
Business applications received (profiles.created) and completed (profiles.decided) each week for the last eight weeks, with the previous eight weeks for comparison:
bash
curl -X POST https://api.cryptocomply.xyz/api/orgs/42/analytics/report/ \
-H "Authorization: Api-Key $CRYPTOCOMPLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"metrics": ["profiles.created", "profiles.decided"],
"filters": [{ "dimension": "profile.type", "op": "eq", "values": ["entity"] }],
"time": {
"from": "2026-08-17",
"to": "2026-10-11",
"granularity": "week",
"timezone": "America/New_York",
"compare": "previous_period"
},
"include": ["totals"]
}'Plot profiles.created and profiles.decided per bucket as two series, and mark the partial week. totals gives the headline for the period and totals.compare the change from the eight weeks before.
profiles.decided counts a profile in the week it was decided, whenever it arrived. To follow each week's applications instead, use cohort.completion_rate: of the profiles received that week, the share decided so far. Recent weeks keep rising until every application is decided.
Median and P90 review turnaround
How long business reviews take, month by month, from creation to the first Approved or Declined decision:
json
{
"metrics": ["profiles.time_to_decision_p50", "profiles.time_to_decision_p90"],
"filters": [{ "dimension": "profile.type", "op": "eq", "values": ["entity"] }],
"time": { "from": "2026-04-01", "to": "2026-09-30", "granularity": "month", "timezone": "America/New_York" },
"include": ["totals"]
}Values are in seconds: divide by 86,400 for days. The median (P50) is the typical review; the 90th percentile (P90) shows how long the slowest one in ten takes. A month with no decisions reads null. Use totals for the period's figure — a median of the months is not the median of the period.
To measure by when the application arrived rather than when it was decided, use cohort.turnaround_p50 and cohort.turnaround_p90.
Open KYB reviews by status and age
The reviews still waiting for a decision right now, by review status and by how long they've been in it:
json
{
"metrics": ["profiles.pending_review"],
"dimensions": ["profile.review_status", "review.age"],
"filters": [{ "dimension": "profile.type", "op": "eq", "values": ["entity"] }],
"include": ["totals"]
}profiles.pending_review is a current count, so leave out time.granularity. review.age buckets each profile by the whole days in its current status: 0_2 (0-2 Days), 3_7 (3-7 Days), 8_14 (8-14 Days) and 15_plus (15+ Days). Each row is one status and age pair, such as Under Review for 8-14 Days. Lay them out as a stacked bar or a heat map, with totals as the headline count of open reviews.
Limits, rate limits and errors
Limits. A report covers at most 366 days, groups by at most 2 dimensions, takes up to 20 metrics, and may produce at most 5000 rows. The catalog's limits gives the current values.
Rate limits. An API key can call the analytics endpoints 30 times a minute, a budget of its own that doesn't use up its owner's. The general limit of 600 reads and 120 writes a minute per user still applies. Past it, the API answers 429 with a Retry-After header giving the seconds to wait. Since reports are cached for up to an hour, refreshing a dashboard every few minutes is plenty.
Errors. Every error has the same body: a detail for people, a stable code to branch on, and a correlation_id to quote to support. A refused report answers 422 with one entry in errors[] pointing at the part of the body to fix, and its metadata lists what is allowed:
json
{
"detail": "review.age does not apply to profiles.created.",
"code": "analytics.incompatible_dimension",
"errors": [
{
"pointer": "/dimensions/1",
"code": "invalid",
"detail": "review.age does not apply to profiles.created.",
"metadata": {
"dimension": "review.age",
"metrics": ["profiles.created"],
"allowed": ["profile.creation_source", "profile.group", "profile.relationship_status", "profile.review_status", "profile.risk", "profile.type"]
}
}
],
"correlation_id": "01JA9X5V0M8K3Z7Q2W4E6R8T0Y"
}| Status | code | Means | Fix |
|---|---|---|---|
401 | auth.invalid_credentials, auth.token_expired | The key is missing, invalid, expired or revoked. | Check the Authorization: Api-Key <key> header. |
403 | permission_denied | The key's scope or its owner's role lacks analytics.read, the owner is not a member of the organization, or the URL names an organization other than the key's. | Ask the CryptoComply team for a key scoped to analytics.read. |
422 | analytics.unknown_metric | A metric isn't in your catalog. | Use an id from the catalog. |
422 | analytics.incompatible_dimension | A dimension doesn't apply to every requested metric, or a current count was given a granularity. | Use dimensions every metric lists, drop granularity for current counts, or split the report. |
422 | analytics.invalid_filter | A filter names a dimension a metric can't be filtered by, or a value the dimension doesn't take. | Filter on dimensions and values the catalog lists. |
422 | analytics.range_too_large | The range is longer than 366 days. | Shorten the range or split it into several reports. |
422 | analytics.cardinality_exceeded | More than 2 dimensions, or more than 5000 rows. | Use fewer dimensions, a coarser granularity or a shorter range. |
422 | validation.failed | The body itself is invalid, such as to before from, an unknown timezone, more than 20 metrics or 50 filter values, or sorting by a column you didn't request. | Read errors[] and fix each field. |
429 | rate.throttled | Too many requests. | Wait the seconds in Retry-After. |
See Errors for the status codes every endpoint shares.
What's next?
- REST API reference — every field of both endpoints, under Analytics.
- Authentication & API keys — key expiry and rotation.
- Exports & audit trail — export the profiles behind a number.