Skip to content

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:

EndpointWhat 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:

  • id is what you send in a report. It is stable: if a metric's meaning changes, it gets a new id, and the old one shows deprecated_at and replaced_by.
  • label is a ready-made chart title.
  • unit is count, ratio (a share from 0 to 1) or seconds.
  • time_field says when a row counts — created, first decision, closed, or now for a current count such as open reviews.
  • granularities lists the bucket widths the metric takes. It is empty for a current count, which you read without buckets.
  • dimensions lists what you can group and filter the metric by.
  • additive_over_time is false for rates, percentiles and distinct counts: don't add their buckets up — ask for totals instead.
  • data_available.from is 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:

MetricLabelWhat it counts
profiles.createdProfiles CreatedProfiles added, by when they were created. Related parties are not counted.
profiles.decidedProfiles DecidedProfiles that reached their first decision, Approved or Declined, by when they did.
profiles.approval_rateApproval RateOf the profiles decided, the share first Approved.
profiles.time_to_decision_p50 / _p90Time to Decision (Median / 90th Percentile)Time from creation to the first decision.
profiles.pending_reviewProfiles Pending ReviewProfiles awaiting a decision now: Pending Review, Under Review, RFI Sent, RFI Responded or Escalated.
cohort.completion_rate, cohort.turnaround_p50 / _p90Completion Rate, TurnaroundProfiles received in each bucket and what has happened to them so far.
alerts.open, rfis.open, rfis.overdueOpen Alerts, Open RFIs, Overdue RFIsOpen 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"]
}
FieldWhat it does
metricsUp to 20 metric IDs from the catalog. Each becomes a column.
dimensionsUp to 2 dimension IDs to group by. Every requested metric must take them.
filtersRows 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.toThe first and last day, both included, read in time.timezone. Send both or neither: without them, a report covers the last 30 days.
time.granularityday, 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.timezoneAn IANA timezone such as America/New_York, so "Monday" and "October" mean what they mean to your team. Default UTC.
time.compareprevious_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.
orderSort keys: a requested metric, a dimension or bucket, with dir asc (default) or desc. Without it, rows sort by bucket, then by each dimension.
limitMost 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 }
  }
}
  • meta says how the report was computed: the range and compare_range it covered, the timezone and granularity, how many rows came back, and whether limit cut any (truncated).
  • columns describes each key in a row, in order — use label for headers and unit to format values.
  • rows hold one entry per bucket and group. bucket is 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 is 0 when nothing happened, and null when it is undefined — a rate or median of no profiles.
  • compare on each row holds the same metrics for the matching bucket of the previous period, when you asked for a comparison.
  • partial is true for the bucket that is still running, such as the current week: its figures will grow. Show it differently, or leave it out of trends.
  • totals are each metric recomputed over the whole range, never summed from the rows. Use them for headline numbers, especially for medians and rates.
  • as_of is when the figures were computed. Reports are cached for up to an hour, and cached says 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"
}
StatuscodeMeansFix
401auth.invalid_credentials, auth.token_expiredThe key is missing, invalid, expired or revoked.Check the Authorization: Api-Key <key> header.
403permission_deniedThe 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.
422analytics.unknown_metricA metric isn't in your catalog.Use an id from the catalog.
422analytics.incompatible_dimensionA 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.
422analytics.invalid_filterA 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.
422analytics.range_too_largeThe range is longer than 366 days.Shorten the range or split it into several reports.
422analytics.cardinality_exceededMore than 2 dimensions, or more than 5000 rows.Use fewer dimensions, a coarser granularity or a shorter range.
422validation.failedThe 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.
429rate.throttledToo many requests.Wait the seconds in Retry-After.

See Errors for the status codes every endpoint shares.

What's next? ​